Compare commits

..

18 Commits

Author SHA1 Message Date
noroot
cbe68b16c9 fix: fieldset/legend durch echte Überschriften ersetzt, globales CSS vervollständigt
<legend> positioniert sich per Spezifikation immer auf der oberen
Rahmenlinie seines <fieldset> - das lässt sich nicht per CSS aufheben,
nur kaschieren. Die vorherige "Nachbesserung" (mehr Padding/Breite auf
der legend) war ein Pflaster, kein Fix - kam prompt wieder zum
Vorschein, sobald eine neue .form-section-Seite entstand. .form-section
ist jetzt ein <div>, die Überschrift ein normales <h3
class="form-section-title"> mit voller CSS-Kontrolle. Betrifft alle
neun Vorlagen mit diesem Muster.

Zusätzlich alle restlichen unstyled Formulare (Login, Passwort-Reset,
Einladung annehmen, Nutzer anlegen, CSV-Import, Abteilung anlegen,
Firma umbenennen, Fall-Entscheiden) auf dasselbe form-card/form-section/
form-grid-Muster gehoben, für durchgängig konsistentes globales CSS
statt gemischter roher und gestylter Formulare. antrag_neu.html (der
Fragebogen) bleibt bewusst unverändert - dessen fieldset/legend haben
keinen Rahmen-Konflikt und sind an bereits getestete :has()-Adaptivlogik
gekoppelt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 12:18:35 +02:00
noroot
7db4707bc5 feat: vollständige Firmendaten (Adresse, Abrechnung) bei Firmenanlage
Bei Firmenanlage (Registrierung + Betreiber-Firmenanlage) müssen jetzt
Adresse (Straße, PLZ, Ort, Land) und Abrechnungsdaten (Rechnungsemail,
optional USt-IdNr.) erfasst werden, nicht nur der Firmenname (Migration
0023). USt-IdNr. bewusst optional - Kleinunternehmer nach §19 UStG
haben keine. Neue Seite /verwaltung/firma (admin-only) zum Einsehen/
Nachtragen für bestehende Firmen. store.CreateAccount nimmt jetzt ein
AccountInput statt nur einen Namen entgegen (Signaturänderung betrifft
~20 Testaufrufe, mechanisch umgestellt). register.html/
betreiber_account_neu.html auf form-card/form-grid umgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:36:05 +02:00
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
111 changed files with 7324 additions and 675 deletions

745
CLAUDE.md
View File

@@ -251,7 +251,7 @@ 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. Beide Katalogansichten zeigen
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
@@ -295,24 +295,107 @@ scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
als Tabelle (Name, Anbieter, Verarbeitungsort, AVV, Training aus,
als Tabelle (Name, Anbieter, Verarbeitungsländer, DPF, AVV, Training aus,
Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
Der zentrale Katalog ist aktuell mit 10 recherchierten Einträgen befüllt
(ChatGPT Enterprise, Microsoft 365 Copilot, Claude for Work, GitHub
Copilot Business, DeepL Pro/API, Google Gemini für Google Workspace,
Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly Enterprise,
Otter.ai Business) — deckt damit erstmals alle sechs Zweck-Kategorien
ab (vorher fehlten Bild und Transkription vollständig, Recherche hatte
kein dediziertes Werkzeug). Jede Zusicherung ist mit einer echten,
**Filter & Paginierung** (seit 2026-08-31, `pagination.go` +
`werkzeugFilter` in `betreiber_werkzeug_handlers.go`): beide
Katalog-Tabellen haben eine Filterleiste (Freitext über Name/Anbieter/
Verarbeitungsländer, Dropdown über die zwölf Zweck-Kategorien) und
serverseitige Paginierung (20 Einträge/Seite, `page`-Query-Parameter).
Alles über ein einziges GET-Formular, ohne JavaScript — Prev/Next-
Buttons setzen `page` per `<button type="submit" name="page"
value="...">`, damit q/zweck beim Blättern erhalten bleiben. Auf der
Mandanten-Seite (die pro Zeile zusätzlich Sperren/Entsperren-Formulare
hat) sind Filter und Paginierungs-Buttons bewusst in getrennte
`<form>`-Elemente aufgeteilt statt in eines um die ganze Tabelle —
verschachtelte `<form>`-Elemente sind ungültiges HTML, die
Paginierungs-Buttons tragen `q`/`zweck` deshalb als Hidden-Felder
nach. `pagination.go` ist bewusst generisch (Go-Generics,
`paginate[T any]`) für Wiederverwendung in weiteren Listen (Register,
Fälle, ...) — bisher nur auf den Werkzeugkatalog angewendet, das war
die von den beiden am dringendsten wachsende Tabelle.
Der zentrale Katalog ist aktuell mit 20 recherchierten Einträgen
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
mindestens einem Eintrag ab. **Claude Code ist bewusst ein eigener
Eintrag neben Claude for Work**, kein Duplikat: es läuft lokal auf
Entwicklerrechnern mit direktem Zugriff auf Dateien/Codebasen und
speichert Sitzungsprotokolle lokal im Klartext (bis zu 30 Tage,
konfigurierbar) — ein zusätzliches lokales Aufbewahrungsrisiko, das bei
einer reinen Web-Chat-Anwendung wie Claude for Work nicht existiert,
obwohl beide über dieselben Commercial Terms/DPA laufen. Jede
Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
zwei Einträge (Adobe Firefly: Trainingsausschluss ist nicht Standard;
Otter.ai: Training auf de-identifizierten Aufnahmen, laufende
Rechtsstreitigkeiten) sind bewusst mit kritischen Einschränkungen
versehen statt schöngefärbt, das ist die vorgesehene Funktion des
Katalogs.
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
hergibt: Synthesia und Zapier haben `training_standard`/
`training_opt_out` bewusst NICHT gesetzt, weil keine belegte Aussage
zum Trainingsausschluss gefunden wurde (nicht mit "trainiert
nachweislich" verwechseln — es ist ein "wissen wir nicht, deshalb
keine Haken"). Mehrere Einträge sind bewusst mit kritischen
Einschränkungen versehen statt schöngefärbt, das ist die vorgesehene
Funktion des Katalogs: Adobe Firefly (Trainingsausschluss ist nicht
Standard), Otter.ai (Training auf de-identifizierten Aufnahmen,
laufende Rechtsstreitigkeiten), **Midjourney (kein AVV, keine
EU-Datenresidenz, unbegrenzte Speicherung inkl. Training — laut
CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
geeignet, absichtlich als Negativbeispiel im Katalog, nicht
weggelassen)**.
**Schema-Lücke behoben (2026-08-31, Migration 0016):** der grobe
`verarbeitungsort`-Eimer (`EU`/`USA`/`gemischt`/`on-prem`) wurde durch
`verarbeitungslaender TEXT[]` ersetzt — echte Länder statt Buckets.
Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
bietet dafür eine echte Weltländerliste (`weltLaenderliste` in
`laenderliste.go`, `<select multiple>`, Mehrfachauswahl per Strg/Cmd-
Klick — kein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
(kein einzelnes Land benannt)" für Anbieter, die nur eine Region statt
eines Staates zusichern, z. B. Microsofts EU Data Boundary; "On-Premise
(selbst gehostet)"). Der harte Filter `eu_verarbeitung`
(`internal/rules/evaluate.go`, `alleLaenderInEUEWR`) verlangt jetzt,
dass ALLE genannten Länder EU/EWR-Mitgliedstaaten sind — eine leere
Liste gilt als nicht erfüllt, nicht als Freifahrtschein. Zusätzlich neues
Feld `dpf_zertifiziert` (Checkbox "EU-US Data Privacy Framework
zertifiziert"): DPF ist eine schmalere, gerichtlich schon zweimal
gekippte Rechtsgrundlage (Safe Harbor, Privacy Shield) für USA-Transfers,
bisher nur als Fließtext in "Einschränkungen" erwähnt, jetzt strukturiert
und damit künftig filterbar. Alle 19 Katalogeinträge wurden mit den
tatsächlichen, bereits recherchierten Ländern nachgepflegt (z. B.
Mistral → Frankreich, DeepL → Deutschland, Synthesia → Irland, Claude
for Work → USA) statt der pauschalen alten Buckets.
Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
Wertebereich, nicht an einer fachlichen Entscheidung.
**`dpf_zertifiziert` recherchiert (2026-09-01), Offener Punkt erledigt.**
Für alle 20 Einträge wurde gezielt gegen das offizielle Register
(dataprivacyframework.gov, per Participant-ID einzeln nachvollziehbar)
geprüft — nicht gegen die JS-Suchseite selbst (nicht scrapebar), sondern
gegen das dahinterliegende, von ihr selbst genutzte API. Ergebnis: 12
zertifiziert (Microsoft 365 Copilot, GitHub Copilot Business, Google
Gemini, Perplexity Enterprise Pro, Adobe Firefly, Otter.ai Business,
Notion AI, Grammarly Business, ElevenLabs, Zendesk AI, Canva Magic
Studio, Zapier), 5 recherchiert aber nicht im Register gefunden
(ChatGPT Enterprise, Claude for Work, Claude Code, Midjourney, Julius
AI — `false` heißt hier jetzt "geprüft, kein Eintrag gefunden", nicht
mehr "nicht recherchiert"), 3 nicht anwendbar (DeepL, Le Chat/Mistral,
Synthesia — reine EU-Verarbeitung ohne US-Rechtsträger im Register, DPF
regelt EU→USA-Transfers). Besonderheiten als `einschraenkungen`-Eintrag
je Werkzeug festgehalten: Perplexity hat nur EU-US/UK-, keine
Swiss-US-Zertifizierung; Notion AIs Zertifizierung ist als "Active" mit
bereits abgelaufenem Datum in Re-Zertifizierung; Grammarly ist im
Register unter dem fusionierten Rechtsträger "Superhuman Platform Inc."
gelistet, nicht mehr unter "Grammarly, Inc.".
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
@@ -382,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
@@ -408,6 +494,443 @@ 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.
---
## Firmendaten: Adresse und Abrechnung (2026-09-01, Migration 0023)
Auf Nutzerwunsch: bei jeder Firmenanlage (öffentliche Registrierung
`POST /register` UND Betreiber-Firmenanlage `POST /betreiber/accounts`)
müssen jetzt vollständige Firmendaten erfasst werden, nicht nur der
Name. `account` bekommt sechs neue Spalten: `strasse`, `plz`, `ort`,
`land` (Adresse) sowie `ust_id`, `rechnungsemail` (Abrechnung).
Pflichtfelder bei Neuanlage: Name, Straße, PLZ, Ort, Land,
Rechnungsemail. **Bewusst optional:** USt-IdNr. — Kleinunternehmer nach
§19 UStG haben keine, eine Pflichtangabe wäre hier fachlich falsch.
`store.AccountInput` bündelt diese Felder für `CreateAccount` (Signatur
geändert von `(ctx, name string)` auf `(ctx, AccountInput)` — betrifft
~20 Testaufrufe, mechanisch auf `AccountInput{Name: "..."}` umgestellt)
und die neue Methode `UpdateAccountDetails` (Name + alle Firmendaten in
einem Aufruf, getrennt von der bestehenden `UpdateAccount`, die nur den
Namen ändert — z. B. für die schnelle Tippfehlerkorrektur durch den
Betreiber). Migrationsspalten sind `NOT NULL DEFAULT ''` statt einer
harten Pflicht ohne Default: bestehende, vor dieser Migration angelegte
Accounts haben diese Daten schlicht noch nicht, die Migration darf sie
nicht blockieren — die Pflicht gilt nur auf Anwendungsebene für NEUE
Firmen.
**Neue Seite für bestehende Firmen:** `GET/POST /verwaltung/firma`
(Ebene 4, admin-only, eigener Nav-Punkt "Firmendaten") — zum Einsehen
und Nachtragen/Korrigieren der eigenen Adress-/Abrechnungsdaten, auch
für Accounts, die vor dieser Migration entstanden sind und die Felder
sonst dauerhaft leer hätten. `register.html`/`betreiber_account_neu.html`
wurden dabei auf das `.form-card`/`.form-section`/`.form-grid`-Muster
umgestellt (vorher unstylte Rohformulare) — bei sieben-plus Feldern
sonst unübersichtlich.
Live end-to-end verifiziert: beide Anlage-Wege (Registrierung UND
Betreiber-Firmenanlage) speichern alle Felder korrekt, die neue
Firmendaten-Seite zeigt den aktuellen Stand vorausgefüllt und
Änderungen werden korrekt persistiert.
---
## 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
@@ -496,7 +1019,7 @@ Karte mit Rahmen/Schatten, Ant-Design-Tokens aus `design/enterprise.css`
(thematische Abschnitte mit Titel, per `border-top` getrennt), darin
`.form-grid` (2 Spalten ab 640px für kurze Felder wie Name/Anbieter
nebeneinander, `.form-full` erzwingt volle Breite für lange Felder).
Checkbox-/Radio-Gruppen mit vielen Optionen (Verarbeitungsort, die
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
@@ -513,6 +1036,140 @@ 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.
**Endgültige Korrektur — `fieldset`/`legend` durch echte Überschriften
ersetzt (2026-09-01):** die obige "Nachbesserung" war ein Pflaster auf
dem eigentlichen Problem, nicht die Ursache behoben — sie kam prompt
wieder zum Vorschein, sobald eine neue `.form-section`-Seite entstand
(Betreiber-Firmenanlage), und der Nutzer wies zu Recht darauf hin, dass
das "immer wieder derselbe Fehler" sei. Der eigentliche Grund:
`<legend>` positioniert sich **laut Spezifikation immer** auf der
oberen Rahmenlinie seines `<fieldset>` — das ist seine definierte
Funktion (die Box zu betiteln) und lässt sich durch kein Padding/keine
Breite aufheben, nur kaschieren. `.form-section` ist deshalb jetzt ein
`<div>` (kein `<fieldset>` mehr), die Überschrift ein normales `<h3
class="form-section-title">` — verhält sich wie jede andere Überschrift,
volle CSS-Kontrolle, keine Sonderregeln. Betrifft alle neun Vorlagen,
die das Muster nutzen (`register.html`, `betreiber_account_neu.html`,
`firmendaten.html`, `login.html`, `loeschfristen_liste.html`,
`genehmiger_rollen_liste.html`, `freigabe_regeln_liste.html`,
`email_vorlage_bearbeiten.html`, `betreiber_werkzeug_form.html`).
`antrag_neu.html` (der Fragebogen) bleibt bewusst unverändert — dessen
`fieldset`/`legend` sind einfache, nicht-kartierte Elemente ohne
Rahmen-Konflikt, an eine bereits getestete `:has()`-Adaptivlogik
(Folgefragen ein-/ausblenden) gekoppelt; ein Umbau hätte unnötiges
Risiko für rein kosmetischen Gewinn bedeutet, da dort gar kein
sichtbarer Rahmen existiert, mit dem die Legend kollidieren könnte.
**Restliche unstyled Formulare auf dasselbe Muster gehoben
(2026-09-01):** `passwort_vergessen.html`, `passwort_zuruecksetzen.html`,
`einladung.html`, `nutzer_neu.html` (Rollenauswahl jetzt `.chip-group`
statt nacktem `fieldset`), `nutzer_import.html`, `abteilung_liste.html`
(Anlegen-Formular), `betreiber_account_detail.html` (Umbenennen-
Formular), sowie der komplette "Entscheiden"-Bereich in
`fall_detail.html` (Entscheidung jetzt `.chip-group` statt nacktem
`fieldset`) — alle jetzt `.form-card`/`.form-section`/`.form-grid`
statt roher `<label>`+`<input>`-Ketten. Explizit **nicht** angefasst:
`antrag_neu.html` (siehe oben), sowie reine Listen-Aktionsformulare
(Filter, Löschen-/Aktivieren-Buttons in `.beitraege-liste`-Zeilen) —
die haben ihr eigenes, bereits passendes CSS-Muster und keinen
Formular-Charakter im hier gemeinten Sinn.
**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
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
@@ -652,12 +1309,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).
---
@@ -878,15 +1545,13 @@ journalctl -u deklarix -f
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
Rückmeldung dazu bisher eingeholt.
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
- **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.
- ~~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).
@@ -901,15 +1566,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

@@ -13,30 +13,56 @@ import (
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
// Strasse/PLZ/Ort/Land/UStID/Rechnungsemail sind die Firmen- und
// Abrechnungsdaten (Migration 0023) — bei bestehenden, vor dieser
// Migration angelegten Accounts können sie leer sein, siehe dort.
type Account struct {
ID string
Name string
EinladungToken string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
CreatedAt time.Time
}
const accountColumns = `id, name, einladung_token, created_at`
// AccountInput bündelt die Firmendaten für CreateAccount/
// UpdateAccountDetails. Name ist die einzige Pflichtangabe auf
// Store-Ebene — welche der übrigen Felder ein Formular tatsächlich
// verlangt (z. B. Adresse bei Neuanlage), entscheidet die Web-Schicht,
// nicht der Store (Tests legen Accounts oft ohne vollständige
// Firmendaten an, das ist auf Store-Ebene kein Fehler).
type AccountInput struct {
Name string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
}
const accountColumns = `id, name, einladung_token, strasse, plz, ort, land, ust_id, rechnungsemail, created_at`
func scanAccount(row interface {
Scan(dest ...any) error
}) (Account, error) {
var a Account
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.Strasse, &a.PLZ, &a.Ort, &a.Land, &a.UStID, &a.Rechnungsemail, &a.CreatedAt)
return a, err
}
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
func (s *Store) CreateAccount(ctx context.Context, in AccountInput) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO account (name, strasse, plz, ort, land, ust_id, rechnungsemail)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+accountColumns,
name,
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
)
a, err := scanAccount(row)
if err != nil {
@@ -46,9 +72,10 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
}
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
// Betreiber-gestützten Anlage korrigieren).
// Betreiber-gestützten Anlage korrigieren) — ändert bewusst nur den
// Namen, nicht die übrigen Firmendaten, siehe UpdateAccountDetails.
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
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
@@ -59,9 +86,29 @@ func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, er
return a, nil
}
// UpdateAccountDetails aktualisiert Name und Firmen-/Abrechnungsdaten
// gemeinsam — genutzt von der Firmendaten-Seite (Ebene 4, admin), auf
// der ein Mandant seine eigenen Angaben pflegt/nachträgt.
func (s *Store) UpdateAccountDetails(ctx context.Context, id string, in AccountInput) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `
UPDATE account SET name = $2, strasse = $3, plz = $4, ort = $5, land = $6, ust_id = $7, rechnungsemail = $8
WHERE id = $1
RETURNING `+accountColumns,
id, in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: update account details: %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
@@ -77,7 +124,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
@@ -92,7 +139,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)
}
@@ -105,7 +152,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

@@ -3,6 +3,8 @@ package store_test
import (
"context"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
@@ -37,7 +39,7 @@ func TestListAccounts(t *testing.T) {
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Neuer Mandant fuer ListAccounts"})
if err != nil {
t.Fatalf("CreateAccount: %v", 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

@@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -30,10 +30,63 @@ func TestAccountCRUD(t *testing.T) {
}
}
func TestCreateAccountMitFirmendaten(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{
Name: "Vollstaendig GmbH", Strasse: "Musterstraße 1", PLZ: "12345", Ort: "Musterstadt",
Land: "Deutschland", UStID: "DE123456789", Rechnungsemail: "rechnung@vollstaendig.example.com",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Strasse != "Musterstraße 1" || acc.PLZ != "12345" || acc.Ort != "Musterstadt" ||
acc.Land != "Deutschland" || acc.UStID != "DE123456789" || acc.Rechnungsemail != "rechnung@vollstaendig.example.com" {
t.Fatalf("Account = %+v, Firmendaten unvollständig gespeichert", acc)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Rechnungsemail != acc.Rechnungsemail {
t.Fatalf("Rechnungsemail nach GetAccount = %q, want %q", got.Rechnungsemail, acc.Rechnungsemail)
}
}
func TestUpdateAccountDetails(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alte Firma", Ort: "Altstadt"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
updated, err := s.UpdateAccountDetails(ctx, acc.ID, store.AccountInput{
Name: "Neue Firma", Strasse: "Neue Straße 2", PLZ: "54321", Ort: "Neustadt",
Land: "Österreich", UStID: "", Rechnungsemail: "buchhaltung@neue-firma.example.com",
})
if err != nil {
t.Fatalf("UpdateAccountDetails: %v", err)
}
if updated.Name != "Neue Firma" || updated.Ort != "Neustadt" || updated.Land != "Österreich" {
t.Fatalf("Account nach Update = %+v, nicht wie erwartet aktualisiert", updated)
}
}
func TestUpdateAccountDetailsNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, err := s.UpdateAccountDetails(ctx, "00000000-0000-0000-0000-000000000000", store.AccountInput{Name: "X"})
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestUpdateAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alter Name GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -66,7 +119,7 @@ func TestUpdateAccountNotFound(t *testing.T) {
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -88,7 +141,7 @@ func TestGetAccountByEinladungToken(t *testing.T) {
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

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,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,6 @@
ALTER TABLE account DROP COLUMN strasse;
ALTER TABLE account DROP COLUMN plz;
ALTER TABLE account DROP COLUMN ort;
ALTER TABLE account DROP COLUMN land;
ALTER TABLE account DROP COLUMN ust_id;
ALTER TABLE account DROP COLUMN rechnungsemail;

View File

@@ -0,0 +1,20 @@
-- Adress- und Abrechnungsdaten der Firma — bisher trug account nur den
-- Namen. NOT NULL DEFAULT '' statt einer harten NOT-NULL-Pflicht ohne
-- Default: bestehende Accounts (vor dieser Migration angelegt) haben
-- diese Daten schlicht noch nicht, das darf die Migration nicht
-- blockieren. Die Anwendungsschicht erzwingt Pflichtfelder nur für NEU
-- angelegte Firmen (Registrierung, Betreiber-Firmenanlage); bestehende
-- Firmen werden nicht rückwirkend gezwungen, sie können es über die
-- neue Firmendaten-Seite (Ebene 4) nachtragen.
ALTER TABLE account ADD COLUMN strasse TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN plz TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN ort TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN land TEXT NOT NULL DEFAULT '';
-- Umsatzsteuer-ID ist bewusst optional (NOT NULL DEFAULT '', keine
-- Pflicht auch bei Neuanlage) — Kleinunternehmer nach §19 UStG haben
-- keine.
ALTER TABLE account ADD COLUMN ust_id TEXT NOT NULL DEFAULT '';
-- Rechnungsemail kann von der E-Mail des ersten (admin-)Logins
-- abweichen (z. B. eine buchhaltung@-Adresse) — eigenes Feld statt
-- Wiederverwendung der Login-E-Mail.
ALTER TABLE account ADD COLUMN rechnungsemail TEXT NOT NULL DEFAULT '';

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

@@ -36,7 +36,7 @@ func scanSession(row interface {
// 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) {
row := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO session (token, user_id, expires_at)
VALUES ($1, $2, $3)
RETURNING `+sessionColumns,
@@ -55,7 +55,7 @@ func (s *Store) CreateSession(ctx context.Context, token, userID string, expires
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
row := s.Pool.QueryRow(ctx, `
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,
@@ -74,7 +74,7 @@ func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, im
// 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) {
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
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
@@ -87,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

@@ -36,7 +36,7 @@ func openTestStore(t *testing.T) *store.Store {
// testAccountID legt einen Mandanten an und liefert dessen ID.
func testAccountID(t *testing.T, s *store.Store) string {
t.Helper()
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
acc, err := s.CreateAccount(context.Background(), store.AccountInput{Name: "Test-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

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,15 +16,27 @@ import (
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
// muss belegbar sein.
type Werkzeug struct {
ID string
AccountID *string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
ID string
AccountID *string
Name string
Anbieter string
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
Verarbeitungslaender []string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
// übrigen Booleans hier.
DPFZertifiziert bool
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
Zertifizierungen []string
Subprozessoren []string
@@ -40,25 +52,26 @@ type Werkzeug struct {
// Create/Update — bei 15 Feldern lesbarer als eine positionale
// Parameterliste.
type WerkzeugInput struct {
AccountID *string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage *int
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
Quelle string
AccountID *string
Name string
Anbieter string
Verarbeitungslaender []string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
DPFZertifiziert bool
AufbewahrungTage *int
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
Quelle string
}
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren,
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
func scanWerkzeug(row interface {
@@ -66,8 +79,8 @@ func scanWerkzeug(row interface {
}) (Werkzeug, error) {
var w Werkzeug
err := row.Scan(
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
)
return w, err
@@ -79,6 +92,9 @@ func scanWerkzeug(row interface {
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
// Fall wie früher bei finding.sources).
func normalizeWerkzeugSlices(in *WerkzeugInput) {
if in.Verarbeitungslaender == nil {
in.Verarbeitungslaender = []string{}
}
if in.Zertifizierungen == nil {
in.Zertifizierungen = []string{}
}
@@ -96,15 +112,15 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
// 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, subprozessoren,
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING `+werkzeugColumns,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
@@ -121,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, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13,
letzte_pruefung = $14, quelle = $15, updated_at = now()
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
letzte_pruefung = $15, quelle = $16, updated_at = now()
WHERE id = $1
RETURNING `+werkzeugColumns,
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
@@ -145,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
@@ -158,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)
}
@@ -172,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
@@ -202,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 {
@@ -232,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 {
@@ -255,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
@@ -268,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 {
@@ -282,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

@@ -13,21 +13,21 @@ func intPtr(v int) *int { return &v }
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
return store.WerkzeugInput{
AccountID: accountID,
Name: name,
Anbieter: "Beispiel-Anbieter GmbH",
Verarbeitungsort: "EU",
AVVVerfuegbar: true,
AVVURL: "https://beispiel.example/avv",
TrainingOptOut: true,
TrainingStandard: true,
AufbewahrungTage: intPtr(30),
Zertifizierungen: []string{"ISO 27001"},
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
GeeigneteZwecke: []string{"Textgenerierung"},
Einschraenkungen: nil,
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
Quelle: "https://beispiel.example/beleg",
AccountID: accountID,
Name: name,
Anbieter: "Beispiel-Anbieter GmbH",
Verarbeitungslaender: []string{"Deutschland"},
AVVVerfuegbar: true,
AVVURL: "https://beispiel.example/avv",
TrainingOptOut: true,
TrainingStandard: true,
AufbewahrungTage: intPtr(30),
Zertifizierungen: []string{"ISO 27001"},
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
GeeigneteZwecke: []string{"Textgenerierung"},
Einschraenkungen: nil,
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
Quelle: "https://beispiel.example/beleg",
}
}

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"
@@ -289,16 +290,25 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
}
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
for i, w := range werkzeuge {
aufbewahrungTage := 0
if w.AufbewahrungTage != nil {
aufbewahrungTage = *w.AufbewahrungTage
}
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
}}
}
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
var loeschfristMaxTage *int
loeschfristen, err := s.store.ListLoeschfristEinstellungenForAccount(ctx, accountID)
if err != nil {
return err
}
for _, l := range loeschfristen {
if l.DatenklasseID == datenklasse.ID {
maxTage := l.MaxTage
loeschfristMaxTage = &maxTage
}
}
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs, loeschfristMaxTage)
in.ZulaessigeWerkzeuge = zulaessig
for _, a := range ausgeschlossen {
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
@@ -334,9 +344,11 @@ type antragListItem struct {
}
type antragListData struct {
Title string
Nav navData
Antraege []antragListItem
Title string
Nav navData
Antraege []antragListItem
Suche string
Pagination pagination
}
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
@@ -351,8 +363,16 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
q := strings.TrimSpace(r.URL.Query().Get("q"))
var gefiltert []store.Antrag
for _, a := range list {
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
gefiltert = append(gefiltert, a)
}
}
pag := paginationFromRequest(r, len(gefiltert))
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
for _, a := range paginate(gefiltert, pag) {
data.Antraege = append(data.Antraege, antragListItem{
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),

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,15 +1,18 @@
package web
import (
"context"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
)
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 +26,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-
@@ -40,11 +47,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
accountName := r.FormValue("account_name")
accountInput := store.AccountInput{
Name: r.FormValue("account_name"),
Strasse: r.FormValue("strasse"),
PLZ: r.FormValue("plz"),
Ort: r.FormValue("ort"),
Land: r.FormValue("land"),
UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer)
Rechnungsemail: r.FormValue("rechnungsemail"),
}
email := r.FormValue("email")
password := r.FormValue("password")
if accountName == "" || email == "" || password == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
if accountInput.Name == "" || email == "" || password == "" ||
accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" ||
accountInput.Land == "" || accountInput.Rechnungsemail == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder außer USt-IdNr. sind Pflicht"})
return
}
@@ -54,20 +71,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, accountInput)
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)

View File

@@ -6,6 +6,8 @@ import (
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
@@ -13,9 +15,9 @@ func TestBetreiberKannFirmaAnlegen(t *testing.T) {
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", mergeValues(url.Values{
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
})
}, firmenPflichtfelder()))
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
@@ -62,7 +64,7 @@ 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")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Alter Name"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -2,9 +2,11 @@ package web
import (
"net/http"
"strings"
"time"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
)
type betreiberDashboardData struct {
@@ -52,9 +54,11 @@ type betreiberAccountListItem struct {
}
type betreiberAccountListData struct {
Title string
Nav navData
Accounts []betreiberAccountListItem
Title string
Nav navData
Accounts []betreiberAccountListItem
Suche string
Pagination pagination
}
// handleBetreiberAccountList listet alle Mandanten der Plattform.
@@ -64,8 +68,16 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
q := strings.TrimSpace(r.URL.Query().Get("q"))
var gefiltert []store.Account
for _, a := range accounts {
if matchesQuery(q, a.Name) {
gefiltert = append(gefiltert, a)
}
}
pag := paginationFromRequest(r, len(gefiltert))
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r), Suche: q, Pagination: pag}
for _, a := range paginate(gefiltert, pag) {
data.Accounts = append(data.Accounts, betreiberAccountListItem{
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
@@ -99,11 +111,21 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountName := r.FormValue("account_name")
accountInput := store.AccountInput{
Name: r.FormValue("account_name"),
Strasse: r.FormValue("strasse"),
PLZ: r.FormValue("plz"),
Ort: r.FormValue("ort"),
Land: r.FormValue("land"),
UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer)
Rechnungsemail: r.FormValue("rechnungsemail"),
}
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 accountInput.Name == "" || email == "" || password == "" ||
accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" ||
accountInput.Land == "" || accountInput.Rechnungsemail == "" {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht"}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -120,7 +142,7 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
}
ctx := r.Context()
acc, err := s.store.CreateAccount(ctx, accountName)
acc, err := s.store.CreateAccount(ctx, accountInput)
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 {
@@ -135,7 +157,21 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
}
return
}
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
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, accountInput.Name+" / "+email); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -218,20 +254,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,

View File

@@ -14,11 +14,57 @@ import (
"github.com/netcell-it/deklarix/internal/store"
)
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
// werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
type werkzeugFilter struct {
Suche string
Zweck string
}
func istGueltigerVerarbeitungsort(v string) bool {
for _, o := range gueltigeVerarbeitungsorte {
if o == v {
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
return werkzeugFilter{
Suche: strings.TrimSpace(r.URL.Query().Get("q")),
Zweck: r.URL.Query().Get("zweck"),
}
}
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
// nur exportierte Methoden per Reflection aufrufen kann.
func (f werkzeugFilter) Aktiv() bool {
return f.Suche != "" || f.Zweck != ""
}
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
if f.Zweck != "" {
gefunden := false
for _, z := range wz.GeeigneteZwecke {
if z == f.Zweck {
gefunden = true
break
}
}
if !gefunden {
return false
}
}
if f.Suche != "" {
q := strings.ToLower(f.Suche)
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
if !strings.Contains(heuhaufen, q) {
return false
}
}
return true
}
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
// nur für Verarbeitungsländer statt Zwecke.
func istGueltigesLand(v string) bool {
for _, l := range weltLaenderliste {
if l == v {
return true
}
}
@@ -62,18 +108,19 @@ func splitListe(s string) []string {
}
type werkzeugListItem struct {
ID string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
Zertifizierungen string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS 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
@@ -90,9 +137,12 @@ func formatAufbewahrung(tage *int) string {
}
type werkzeugListeData struct {
Title string
Nav navData
Werkzeuge []werkzeugListItem
Title string
Nav navData
Werkzeuge []werkzeugListItem
Filter werkzeugFilter
ZweckeOptionen []string
Pagination pagination
}
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
@@ -130,12 +180,26 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
filter := werkzeugFilterFromRequest(r)
var gefiltert []store.Werkzeug
for _, wz := range werkzeuge {
if filter.matches(wz) {
gefiltert = append(gefiltert, wz)
}
}
pag := paginationFromRequest(r, len(gefiltert))
seite := paginate(gefiltert, pag)
data := werkzeugListeData{
Title: "Werkzeugkatalog", Nav: navFor(r),
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
}
for _, wz := range seite {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
DPFZertifiziert: wz.DPFZertifiziert,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
@@ -152,32 +216,33 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
type werkzeugFormData struct {
Title string
Nav navData
ActionBase string
ID string // leer bei "neu"
Name string
Anbieter string
Verarbeitungsorte []string
Verarbeitungsort string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
Zertifizierungen string
Subprozessoren string
ZweckeOptionen []string
ZweckeAusgewaehlt map[string]bool
Einschraenkungen string
LetztePruefung string
Quelle string
Error string
Title string
Nav navData
ActionBase string
ID string // leer bei "neu"
Name string
Anbieter string
LaenderOptionen []string
LaenderAusgewaehlt map[string]bool
DPFZertifiziert bool
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
Zertifizierungen string
Subprozessoren string
ZweckeOptionen []string
ZweckeAusgewaehlt map[string]bool
Einschraenkungen string
LetztePruefung string
Quelle string
Error string
}
// zweckeAusgewaehltVon baut die Checkbox-Vorbelegung aus einer bereits
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
// gespeicherten oder erneut eingereichten Liste von Zwecken.
func zweckeAusgewaehltVon(ausgewaehlt []string) map[string]bool {
func auswahlSet(ausgewaehlt []string) map[string]bool {
m := make(map[string]bool, len(ausgewaehlt))
for _, z := range ausgewaehlt {
m[z] = true
@@ -194,12 +259,13 @@ func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, act
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
}
return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
Name: wz.Name, Anbieter: wz.Anbieter,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(wz.GeeigneteZwecke),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
@@ -208,7 +274,8 @@ func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, act
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
@@ -223,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")
@@ -250,21 +322,22 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
}
return store.WerkzeugInput{
AccountID: accountID,
Name: name,
Anbieter: anbieter,
Verarbeitungsort: verarbeitungsort,
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on",
TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: aufbewahrungTage,
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
GeeigneteZwecke: geeigneteZwecke,
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
LetztePruefung: letztePruefung,
Quelle: quelle,
AccountID: accountID,
Name: name,
Anbieter: anbieter,
Verarbeitungslaender: verarbeitungslaender,
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on",
TrainingStandard: r.FormValue("training_standard") == "on",
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
AufbewahrungTage: aufbewahrungTage,
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
GeeigneteZwecke: geeigneteZwecke,
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
LetztePruefung: letztePruefung,
Quelle: quelle,
}, ""
}
@@ -273,13 +346,15 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
// statt das Formular zu leeren.
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(r.Form["geeignete_zwecke"]),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
Quelle: r.FormValue("quelle"), Error: errMsg,
}

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"},
@@ -104,7 +106,7 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(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(), "Mandant mit eigenem Werkzeug")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mandant mit eigenem Werkzeug"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -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

@@ -6,12 +6,14 @@ import (
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestEinladungFormZeigtFirmenname(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -41,7 +43,7 @@ func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

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

@@ -19,7 +19,7 @@ import (
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Fachebene-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -62,7 +62,7 @@ func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
}
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Anderer Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -174,7 +174,7 @@ func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testi
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
fs := newFakeStore()
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mit Katalog"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -225,7 +225,7 @@ func TestFallDetailRejectsForeignAccount(t *testing.T) {
s := newServer(t, fs)
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Fremder Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -0,0 +1,74 @@
// Firmendaten (Migration 0023) — Adress- und Abrechnungsdaten des
// eigenen Mandanten einsehen/pflegen. Bei der Neuanlage (Registrierung,
// Betreiber-Firmenanlage) sind diese Felder Pflicht; bestehende, davor
// angelegte Accounts können sie hier nachtragen.
package web
import (
"net/http"
"github.com/netcell-it/deklarix/internal/store"
)
type firmendatenData struct {
Title string
Nav navData
Name string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
Error string
Gespeichert bool
}
func (s *Server) handleFirmendaten(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Firmendaten konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := firmendatenData{
Title: "Firmendaten", Nav: navFor(r),
Name: acc.Name, Strasse: acc.Strasse, PLZ: acc.PLZ, Ort: acc.Ort,
Land: acc.Land, UStID: acc.UStID, Rechnungsemail: acc.Rechnungsemail,
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleFirmendatenSpeichern(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in := store.AccountInput{
Name: r.FormValue("account_name"),
Strasse: r.FormValue("strasse"),
PLZ: r.FormValue("plz"),
Ort: r.FormValue("ort"),
Land: r.FormValue("land"),
UStID: r.FormValue("ust_id"),
Rechnungsemail: r.FormValue("rechnungsemail"),
}
if in.Name == "" || in.Strasse == "" || in.PLZ == "" || in.Ort == "" || in.Land == "" || in.Rechnungsemail == "" {
data := firmendatenData{
Title: "Firmendaten", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht",
Name: in.Name, Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land,
UStID: in.UStID, Rechnungsemail: in.Rechnungsemail,
}
if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.UpdateAccountDetails(r.Context(), currentUser(r).AccountID, in); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/firma?gespeichert=1", http.StatusSeeOther)
}

View File

@@ -0,0 +1,87 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestRegisterRequiresFirmenadresse(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Unvollständig GmbH"}, "email": {"unvollstaendig@example.com"}, "password": {"ein-sicheres-passwort"},
// Adresse/Rechnungsemail fehlen bewusst.
})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Pflicht") {
t.Fatalf("erwartet Validierungsfehler, body: %s", resp.Body.String())
}
}
func TestRegisterSpeichertFirmendaten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/register", mergeValues(url.Values{
"account_name": {"Vollständig GmbH"}, "email": {"admin@vollstaendig.example.com"}, "password": {"ein-sicheres-passwort"},
}, firmenPflichtfelder()))
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
user, err := fs.GetUserByEmail(context.Background(), "admin@vollstaendig.example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
acc, err := fs.GetAccount(context.Background(), user.AccountID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if acc.Strasse != "Teststraße 1" || acc.PLZ != "12345" || acc.Rechnungsemail != "rechnung@example.com" {
t.Fatalf("Account = %+v, Firmendaten nicht wie erwartet gespeichert", acc)
}
}
func TestAdminKannFirmendatenBearbeiten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Alte Firma", "admin@example.com", "admin")
getResp := getWithCookie(t, s, cookie, "/verwaltung/firma")
if getResp.Code != http.StatusOK {
t.Fatalf("GET status = %d, want 200", getResp.Code)
}
if !strings.Contains(getResp.Body.String(), "Alte Firma") {
t.Fatalf("erwartet aktuellen Firmennamen im Formular, body: %s", getResp.Body.String())
}
saveResp := postForm(t, s, cookie, "/verwaltung/firma", url.Values{
"account_name": {"Neue Firma"}, "strasse": {"Neue Straße 5"}, "plz": {"99999"}, "ort": {"Neustadt"},
"land": {"Deutschland"}, "rechnungsemail": {"buchhaltung@neue-firma.example.com"},
})
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/firma")
if !strings.Contains(afterResp.Body.String(), "Neue Firma") || !strings.Contains(afterResp.Body.String(), "Neustadt") {
t.Fatalf("gespeicherte Firmendaten fehlen, body: %s", afterResp.Body.String())
}
}
func TestNichtAdminKannFirmendatenNichtBearbeiten(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/firma")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.Code)
}
}

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,284 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
"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, store.AccountInput{Name: "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

@@ -14,8 +14,9 @@ func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `href="/antraege">Meine offenen Anträge <span class="status">1</span>`) {
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", resp.Body.String())
body := resp.Body.String()
if !strings.Contains(body, `href="/antraege"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Meine offenen Anträge") {
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", body)
}
}
@@ -28,8 +29,9 @@ func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `href="/faelle">Posteingang <span class="status">1</span>`) {
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", resp.Body.String())
body := resp.Body.String()
if !strings.Contains(body, `href="/faelle"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Posteingang") {
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", body)
}
}
@@ -54,7 +56,7 @@ func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `href="/betreiber">Zur Plattform`) {
if !strings.Contains(resp.Body.String(), `href="/betreiber"`) || !strings.Contains(resp.Body.String(), "Zur Plattform") {
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
}
if strings.Contains(resp.Body.String(), "Antrag stellen") {

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,35 +11,40 @@ package web
import (
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
type mandantWerkzeugItem struct {
ID string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS 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
Verarbeitungsort string
LetztePruefung string
ID string
Name string
Anbieter string
Verarbeitungslaender string
LetztePruefung string
}
type mandantWerkzeugListeData struct {
Title string
Nav navData
Werkzeuge []mandantWerkzeugItem
Filter werkzeugFilter
ZweckeOptionen []string
Pagination pagination
EigeneWerkzeuge []eigenesWerkzeugItem
}
@@ -69,11 +74,24 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
gesperrt[sp.WerkzeugID] = true
}
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
filter := werkzeugFilterFromRequest(r)
var gefiltert []store.Werkzeug
for _, wz := range zentral {
if filter.matches(wz) {
gefiltert = append(gefiltert, wz)
}
}
pag := paginationFromRequest(r, len(gefiltert))
seite := paginate(gefiltert, pag)
data := mandantWerkzeugListeData{
Title: "Werkzeugkatalog", Nav: navFor(r),
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
}
for _, wz := range seite {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
@@ -87,7 +105,7 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
continue // zentral, oben schon gelistet
}
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
})
}
@@ -99,7 +117,8 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)

View File

@@ -63,6 +63,24 @@ func withAuthContext(r *http.Request, user store.User, impersonator string) *htt
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)
}
}
// 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 {
@@ -72,7 +90,7 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, withAuthContext(r, user, impersonator))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -87,7 +105,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
next(w, withAuthContext(r, user, impersonator))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -108,7 +126,7 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, withAuthContext(r, user, impersonator))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -138,7 +156,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, withAuthContext(r, user, impersonator))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}
@@ -156,7 +174,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, withAuthContext(r, user, impersonator))
s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
}
}

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", mergeValues(url.Values{
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
"password": {"altes-passwort"},
}, firmenPflichtfelder()))
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

@@ -16,7 +16,7 @@ import (
// einen tatsächlichen Registereintrag brauchen.
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
t.Helper()
acc, err := fs.CreateAccount(context.Background(), "Register-Mandant")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Register-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

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,8 +29,18 @@ 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 {
CreateAccount(ctx context.Context, name string) (store.Account, error)
// 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, in store.AccountInput) (store.Account, error)
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
@@ -39,6 +50,16 @@ 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)
@@ -76,6 +97,27 @@ type Store interface {
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error)
GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error)
ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error)
DeleteGenehmigerRolle(ctx context.Context, id string) error
AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error)
ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error)
CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error)
ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error)
GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error)
DeleteFreigabeRegel(ctx context.Context, id string) error
CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error)
GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error)
ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error)
ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error)
EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error)
KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error
}
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
@@ -94,11 +136,15 @@ type Server struct {
store Store
regelwerk Regelwerk
templates *template.Template
mailer mail.Mailer
}
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
// wird einmal beim Start geladen, nicht pro Request.
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
// wird einmal beim Start geladen, nicht pro Request. mailer ist
// austauschbar (siehe mail.FakeMailer für Tests) — ohne konfigurierten
// SMTP-Server schlägt nur der tatsächliche Versand fehl (z. B.
// Passwort-Zurücksetzen), der restliche Server bleibt funktionsfähig.
func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
@@ -108,6 +154,7 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
store: st,
regelwerk: regelwerk,
templates: tmpl,
mailer: mailer,
}
mux := http.NewServeMux()
@@ -118,6 +165,21 @@ 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/firma", s.requireAdmin(s.handleFirmendaten))
mux.HandleFunc("POST /verwaltung/firma", s.requireAdmin(s.handleFirmendatenSpeichern))
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))
@@ -151,6 +213,16 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
mux.HandleFunc("GET /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRollenListe))
mux.HandleFunc("POST /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRolleCreate))
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/loeschen", s.requireAdmin(s.handleGenehmigerRolleDelete))
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder", s.requireAdmin(s.handleGenehmigerRolleMitgliedHinzufuegen))
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder/{userID}/entfernen", s.requireAdmin(s.handleGenehmigerRolleMitgliedEntfernen))
mux.HandleFunc("GET /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelnListe))
mux.HandleFunc("POST /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelCreate))
mux.HandleFunc("POST /verwaltung/freigabe-regeln/{id}/loeschen", s.requireAdmin(s.handleFreigabeRegelDelete))
mux.HandleFunc("GET /freigaben", s.requirePage(s.handleFreigabenListe))
mux.HandleFunc("POST /freigaben/{id}/entscheiden", s.requireAPI(s.handleFreigabeEntscheiden))
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))

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,34 +39,125 @@ 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)
}
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
func (f *fakeStore) CreateAccount(ctx context.Context, in store.AccountInput) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
id := f.newID()
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
acc := store.Account{
ID: id, Name: in.Name, EinladungToken: "einladung-token-" + id,
Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land,
UStID: in.UStID, Rechnungsemail: in.Rechnungsemail, CreatedAt: time.Now(),
}
f.accounts[acc.ID] = acc
return acc, nil
}
@@ -82,6 +174,19 @@ func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.A
return acc, nil
}
func (f *fakeStore) UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
acc.Name, acc.Strasse, acc.PLZ, acc.Ort, acc.Land, acc.UStID, acc.Rechnungsemail =
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail
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()
@@ -172,6 +277,73 @@ func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) e
return nil
}
func (f *fakeStore) SetUserPassword(ctx context.Context, id, passwordHash string) error {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.users[id]
if !ok {
return store.ErrNotFound
}
u.PasswordHash = passwordHash
f.users[id] = u
return nil
}
func (f *fakeStore) CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error) {
f.mu.Lock()
defer f.mu.Unlock()
t := store.PasswordResetToken{
ID: f.newID(), UserID: userID, Token: token,
ExpiresAt: time.Now().Add(store.PasswordResetTokenDuration), CreatedAt: time.Now(),
}
f.passwordResetTokens[t.ID] = t
return t, nil
}
func (f *fakeStore) GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, t := range f.passwordResetTokens {
if t.Token == token && t.UsedAt == nil && t.ExpiresAt.After(time.Now()) {
return t, nil
}
}
return store.PasswordResetToken{}, store.ErrNotFound
}
func (f *fakeStore) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.passwordResetTokens[id]
if !ok {
return store.ErrNotFound
}
now := time.Now()
t.UsedAt = &now
f.passwordResetTokens[id] = t
return nil
}
func (f *fakeStore) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.loeschfristen[accountID] == nil {
f.loeschfristen[accountID] = map[string]int{}
}
f.loeschfristen[accountID][datenklasseID] = maxTage
return store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage, UpdatedAt: time.Now()}, nil
}
func (f *fakeStore) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.LoeschfristEinstellung
for datenklasseID, maxTage := range f.loeschfristen[accountID] {
out = append(out, store.LoeschfristEinstellung{AccountID: accountID, DatenklasseID: datenklasseID, MaxTage: maxTage})
}
return out, nil
}
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -408,9 +580,9 @@ func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput)
defer f.mu.Unlock()
w := store.Werkzeug{
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage,
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
f.werkzeuge[w.ID] = w
@@ -424,10 +596,10 @@ func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.Werk
if !ok {
return store.Werkzeug{}, store.ErrNotFound
}
w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort
w.Name, w.Anbieter, w.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage
w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen
w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
f.werkzeuge[id] = w
return w, nil
@@ -547,6 +719,208 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account
return f.registereintraege[accountID], nil
}
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, Beschreibung: beschreibung, CreatedAt: time.Now()}
f.genehmigerRollen[g.ID] = g
return g, nil
}
func (f *fakeStore) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
g, ok := f.genehmigerRollen[id]
if !ok {
return store.GenehmigerRolle{}, store.ErrNotFound
}
return g, nil
}
func (f *fakeStore) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.GenehmigerRolle
for _, g := range f.genehmigerRollen {
if g.AccountID == accountID {
out = append(out, g)
}
}
return out, nil
}
func (f *fakeStore) DeleteGenehmigerRolle(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.genehmigerRollen[id]; !ok {
return store.ErrNotFound
}
delete(f.genehmigerRollen, id)
return nil
}
func (f *fakeStore) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.nutzerGenehmigerRollen[userID] == nil {
f.nutzerGenehmigerRollen[userID] = map[string]bool{}
}
f.nutzerGenehmigerRollen[userID][genehmigerRolleID] = true
return nil
}
func (f *fakeStore) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.nutzerGenehmigerRollen[userID], genehmigerRolleID)
return nil
}
func (f *fakeStore) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.GenehmigerRolle
for grID := range f.nutzerGenehmigerRollen[userID] {
if g, ok := f.genehmigerRollen[grID]; ok {
out = append(out, g)
}
}
return out, nil
}
func (f *fakeStore) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.GenehmigerMitglied
for userID, set := range f.nutzerGenehmigerRollen {
if !set[genehmigerRolleID] {
continue
}
if u, ok := f.users[userID]; ok {
out = append(out, store.GenehmigerMitglied{UserID: u.ID, Email: u.Email})
}
}
return out, nil
}
func (f *fakeStore) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error) {
f.mu.Lock()
defer f.mu.Unlock()
fr := store.FreigabeRegel{
ID: f.newID(), AccountID: accountID, BedingungTyp: bedingungTyp,
BedingungWert: bedingungWert, GenehmigerRolleID: genehmigerRolleID, CreatedAt: time.Now(),
}
f.freigabeRegeln[fr.ID] = fr
return fr, nil
}
func (f *fakeStore) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.FreigabeRegel
for _, fr := range f.freigabeRegeln {
if fr.AccountID == accountID {
out = append(out, fr)
}
}
return out, nil
}
func (f *fakeStore) GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error) {
f.mu.Lock()
defer f.mu.Unlock()
fr, ok := f.freigabeRegeln[id]
if !ok {
return store.FreigabeRegel{}, store.ErrNotFound
}
return fr, nil
}
func (f *fakeStore) DeleteFreigabeRegel(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.freigabeRegeln[id]; !ok {
return store.ErrNotFound
}
delete(f.freigabeRegeln, id)
return nil
}
func (f *fakeStore) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
fs := store.Freigabeschritt{
ID: f.newID(), AntragID: antragID, GenehmigerRolleID: genehmigerRolleID,
Status: "ausstehend", CreatedAt: time.Now(),
}
f.freigabeschritte[fs.ID] = fs
return fs, nil
}
func (f *fakeStore) GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
fs, ok := f.freigabeschritte[id]
if !ok {
return store.Freigabeschritt{}, store.ErrNotFound
}
return fs, nil
}
func (f *fakeStore) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Freigabeschritt
for _, fs := range f.freigabeschritte {
if fs.AntragID == antragID {
out = append(out, fs)
}
}
return out, nil
}
func (f *fakeStore) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Freigabeschritt
for grID := range f.nutzerGenehmigerRollen[userID] {
for _, fs := range f.freigabeschritte {
if fs.GenehmigerRolleID == grID && fs.Status == "ausstehend" {
out = append(out, fs)
}
}
}
return out, nil
}
func (f *fakeStore) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error) {
f.mu.Lock()
defer f.mu.Unlock()
fs, ok := f.freigabeschritte[id]
if !ok || fs.Status != "ausstehend" {
return store.Freigabeschritt{}, store.ErrNotFound
}
now := time.Now()
fs.Status, fs.EntschiedenVon, fs.EntschiedenAm, fs.Kommentar = status, &entschiedenVon, &now, kommentar
f.freigabeschritte[id] = fs
return fs, nil
}
func (f *fakeStore) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
f.mu.Lock()
defer f.mu.Unlock()
for id, fs := range f.freigabeschritte {
if fs.AntragID == antragID && fs.Status == "ausstehend" && id != ausloesenderSchrittID {
fs.Status = "abgelehnt"
now := time.Now()
fs.EntschiedenAm = &now
fs.Kommentar = "Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde."
f.freigabeschritte[id] = fs
}
}
return nil
}
// ─── Test-Setup ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
@@ -573,11 +947,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
@@ -586,7 +967,7 @@ func newServer(t *testing.T, fs *fakeStore) *web.Server {
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, accountName)
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: accountName})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -646,6 +1027,28 @@ func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
return s, fs, cookie
}
// firmenPflichtfelder liefert die seit Migration 0023 bei Firmenanlage
// (Registrierung und Betreiber-Firmenanlage) verlangten Adress-/
// Abrechnungsfelder — gemeinsame Basis für Tests, die nicht speziell
// deren Validierung prüfen, sonst müsste jeder Testfall sie einzeln
// nachpflegen.
func firmenPflichtfelder() url.Values {
return url.Values{
"strasse": {"Teststraße 1"}, "plz": {"12345"}, "ort": {"Teststadt"},
"land": {"Deutschland"}, "rechnungsemail": {"rechnung@example.com"},
}
}
// mergeValues kopiert alle Werte aus extra in base (base wird mutiert
// und zurückgegeben) — für Tests, die ein Basisformular um wenige
// eigene Felder ergänzen wollen.
func mergeValues(base url.Values, extra url.Values) url.Values {
for k, v := range extra {
base[k] = v
}
return base
}
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
@@ -707,10 +1110,10 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
"password": {"ein-sicheres-passwort"},
})
}, firmenPflichtfelder()))
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
@@ -733,13 +1136,60 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
}
}
func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
"password": {"ein-sicheres-passwort"},
}, firmenPflichtfelder()))
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)
form := url.Values{
form := mergeValues(url.Values{
"account_name": {"A"}, "email": {"doppelt@example.com"},
"password": {"ein-sicheres-passwort"},
}
}, firmenPflichtfelder())
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
t.Fatalf("first register status = %d, want 303", resp.Code)
}
@@ -759,7 +1209,7 @@ func TestLoginWithCorrectPassword(t *testing.T) {
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Bestehender Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -783,7 +1233,7 @@ func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Deklarix Betreiber"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -807,7 +1257,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, _ := fs.CreateAccount(context.Background(), "X")
acc, _ := fs.CreateAccount(context.Background(), store.AccountInput{Name: "X"})
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}

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 {
@@ -432,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 {
@@ -542,8 +551,8 @@ button.entfernen {
}
.fragebogen legend {
font-weight: 500;
font-size: 0.9375rem;
font-weight: 600;
font-size: 1rem;
padding: 0 4px;
}
@@ -573,6 +582,16 @@ button.entfernen {
margin-bottom: 0;
}
/* form-section ist bewusst ein <div>, kein <fieldset> — <legend>
positioniert sich per Spezifikation IMMER auf der oberen Rahmenlinie
des <fieldset> (das ist seine ganze Funktion: die Box zu betiteln),
das lässt sich nicht per CSS aufheben, nur mit fragilen Hacks
kaschieren (padding/Breite anpassen ändert nichts an der
Border-Überlappung selbst — genau das führte zu einer bereits einmal
"reparierten" Überschrift, die nach der nächsten neuen Seite wieder
zu klein/zu randnah aussah). Eine echte Übersicht (.form-section-title)
verhält sich wie jede normale Überschrift: volle CSS-Kontrolle, keine
browserseitigen Sonderregeln. */
.form-section {
border: none;
margin: 0;
@@ -584,12 +603,11 @@ button.entfernen {
border-top: 1px solid var(--color-border);
}
.form-section > legend {
padding: 0 0 12px;
font-size: 0.9375rem;
font-weight: 600;
.form-section-title {
margin: 0 0 16px 0;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-heading);
width: 100%;
}
.form-grid {
@@ -738,6 +756,231 @@ tbody tr:hover {
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
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
@@ -819,4 +1062,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>
@@ -21,12 +25,22 @@
{{end}}
</ul>
{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/abteilungen">
<label for="name">Neue Abteilung</label>
<input type="text" id="name" name="name" required>
<button type="submit">Anlegen</button>
<div class="form-section">
<div class="form-grid">
<div>
<label for="name">Neue Abteilung</label>
<input type="text" id="name" name="name" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

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,26 @@
<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>
<div class="form-card">
<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>
<div class="form-section">
<div class="form-grid">
<div>
<label for="name">Firmenname</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Umbenennen</button>
</div>
</form>
</div>
<h2>Nutzer</h2>
<ul class="beteiligte">

View File

@@ -11,19 +11,65 @@
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<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>
<div class="form-section">
<h3 class="form-section-title">Firma</h3>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="Deutschland" required>
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Abrechnung</h3>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" placeholder="z. B. DE123456789">
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Erster Nutzer (wird Admin)</h3>
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Firma anlegen</button>
</div>
</form>
</div>
</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,8 +29,26 @@
</li>
{{end}}
</ul>
<div class="pagination">
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
<span>
{{if .Pagination.HasPrev}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Suche}}">
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
<button type="submit">&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="/betreiber/accounts/neu">Firma anlegen</a></p>
</div>
</body>
</html>

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,15 +7,15 @@
<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}}">
<fieldset class="form-section">
<legend>Grunddaten</legend>
<div class="form-section">
<h3 class="form-section-title">Grunddaten</h3>
<div class="form-grid">
<div>
<label for="name">Name</label>
@@ -26,19 +26,19 @@
<input type="text" id="anbieter" name="anbieter" value="{{.Anbieter}}" required>
</div>
<div class="form-full">
<label>Verarbeitungsort</label>
{{$gewaehlt := .Verarbeitungsort}}
<div class="chip-group">
{{range .Verarbeitungsorte}}
<label><input type="radio" name="verarbeitungsort" value="{{.}}" {{if eq . $gewaehlt}}checked{{end}} required> {{.}}</label>
<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}}
</div>
</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>
</div>
<fieldset class="form-section">
<legend>Rechtliches &amp; Datenschutz</legend>
<div class="form-section">
<h3 class="form-section-title">Rechtliches &amp; Datenschutz</h3>
<div class="form-grid">
<div class="form-full">
<label>Zusicherungen</label>
@@ -46,6 +46,7 @@
<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">
@@ -65,10 +66,10 @@
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
</div>
</div>
</fieldset>
</div>
<fieldset class="form-section">
<legend>Einsatzbereich</legend>
<div class="form-section">
<h3 class="form-section-title">Einsatzbereich</h3>
<div class="form-grid">
<div class="form-full">
<label>Geeignete Zwecke</label>
@@ -83,10 +84,10 @@
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
</div>
</div>
</fieldset>
</div>
<fieldset class="form-section">
<legend>Beleg</legend>
<div class="form-section">
<h3 class="form-section-title">Beleg</h3>
<div class="form-grid">
<div>
<label for="letzte_pruefung">Letzte Prüfung</label>
@@ -97,7 +98,7 @@
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
</div>
</div>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>

View File

@@ -3,40 +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}}
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
<th>Zuletzt geprüft</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.Zertifizierungen}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
</tr>
{{end}}
</tbody>
</table>
<div class="page-header">
<div>
<h1>Werkzeugkatalog</h1>
<p class="hinweis">Zentral gepflegt, für alle Mandanten sichtbar.</p>
</div>
<a class="page-header-action" href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a>
</div>
{{end}}
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
<form method="get">
<div class="filter-bar">
<input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
<select name="zweck">
<option value="">Alle Zwecke</option>
{{$zweck := .Filter.Zweck}}
{{range .ZweckeOptionen}}
<option value="{{.}}" {{if eq . $zweck}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<button type="submit">Filtern</button>
{{if .Filter.Aktiv}}<a href="/betreiber/werkzeuge">Zurücksetzen</a>{{end}}
</div>
{{if not .Werkzeuge}}
<p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Noch keine zentralen Katalogeinträge.{{end}}</p>
{{else}}
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>DPF</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
<th>Zuletzt geprüft</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td data-label="Name"><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td data-label="Anbieter">{{.Anbieter}}</td>
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
<td data-label="DPF">{{if .DPFZertifiziert}}ja{{else}}nein{{end}}</td>
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
<td data-label="Zertifizierungen">{{.Zertifizierungen}}</td>
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="pagination">
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
<span>
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">&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

@@ -6,15 +6,25 @@
<h1>Einladung annehmen</h1>
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Konto anlegen</button>
<div class="form-section">
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Konto anlegen</button>
</div>
</form>
</div>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>

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}}">
<div class="form-section">
<h3 class="form-section-title">{{.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}}</h3>
<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>
</div>
<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,33 +33,59 @@
<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}}
<div class="form-card">
<form method="post" action="/faelle/{{.ID}}/entscheiden">
<fieldset>
<legend>Entscheidung</legend>
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
</fieldset>
{{if .WerkzeugOptionen}}
<label>Werkzeug (bei Genehmigung erforderlich)
<select name="werkzeug_id">
<option value="">— auswählen —</option>
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
{{end}}
<label>Begründung (Pflicht bei Abweichung vom Vorschlag)
<textarea name="begruendung" rows="3"></textarea>
</label>
<button type="submit">Entscheidung speichern</button>
<div class="form-section">
<div class="form-grid">
<div class="form-full">
<label>Entscheidung</label>
<div class="chip-group">
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
</div>
</div>
{{if .WerkzeugOptionen}}
<div class="form-full">
<label for="werkzeug_id">Werkzeug (bei Genehmigung erforderlich)</label>
<select id="werkzeug_id" name="werkzeug_id">
<option value="">— auswählen —</option>
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
{{end}}
<div class="form-full">
<label for="begruendung">Begründung (Pflicht bei Abweichung vom Vorschlag)</label>
<textarea id="begruendung" name="begruendung" rows="3"></textarea>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Entscheidung speichern</button>
</div>
</form>
</div>
{{else}}
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
{{end}}

View File

@@ -0,0 +1,63 @@
{{define "firmendaten"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page-header">
<div>
<h1>Firmendaten</h1>
<p class="hinweis">Adress- und Abrechnungsdaten deiner Firma.</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="/verwaltung/firma">
<div class="form-section">
<h3 class="form-section-title">Firma</h3>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" value="{{.Name}}" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" value="{{.Strasse}}" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" value="{{.PLZ}}" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" value="{{.Ort}}" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="{{.Land}}" required>
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Abrechnung</h3>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" value="{{.Rechnungsemail}}" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" value="{{.UStID}}" placeholder="z. B. DE123456789">
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{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">
<div class="form-section">
<h3 class="form-section-title">Neue Freigabe-Regel</h3>
<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>
</div>
<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">
<div class="form-section">
<h3 class="form-section-title">Neue Genehmiger-Rolle</h3>
<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>
</div>
<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,18 +3,28 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page page-wide">
<h1>Willkommen bei Deklarix</h1>
{{if .Nav.IsBetreiber}}
<ul class="admin-kacheln">
<li><a href="/betreiber">Zur Plattform</a></li>
<ul class="stat-cards">
<li><a class="stat-card stat-card-action" href="/betreiber"><span class="stat-card-label">Zur Plattform &rarr;</span></a></li>
</ul>
{{else}}
<ul class="admin-kacheln">
<li><a href="/antraege/neu">Antrag stellen</a></li>
<li><a href="/antraege">Meine offenen Anträge <span class="status">{{.OffeneAntraege}}</span></a></li>
<ul class="stat-cards">
<li><a class="stat-card stat-card-action" href="/antraege/neu"><span class="stat-card-label">Antrag stellen &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 href="/faelle">Posteingang <span class="status">{{.OffenerPosteingang}}</span></a></li>
<li>
<a class="stat-card{{if .OffenerPosteingang}} stat-card-hoch{{end}}" href="/faelle">
<span class="stat-card-value">{{.OffenerPosteingang}}</span>
<span class="stat-card-label">Posteingang</span>
</a>
</li>
{{end}}
</ul>
{{end}}

View File

@@ -24,13 +24,19 @@
<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}}
{{if .IsAdmin}}<a href="/verwaltung/firma">Firmendaten</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
{{if .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>

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">
<div class="form-section">
<h3 class="form-section-title">Maximale Aufbewahrungsdauer (Tage)</h3>
<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>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -5,16 +5,29 @@
<div class="page">
<h1>Anmelden</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
<div class="form-card">
<form method="post" action="/login">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
<button type="submit">Anmelden</button>
<div class="form-section">
<h3 class="form-section-title">Anmelden</h3>
<div class="form-grid">
<div class="form-full">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-full">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Anmelden</button>
</div>
</form>
</div>
<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,38 +3,54 @@
<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}}
<div class="table-scroll">
<div class="table-responsive">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td>{{.Name}}</td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
<td>
<td data-label="Name">{{.Name}}</td>
<td data-label="Anbieter">{{.Anbieter}}</td>
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
<td data-label="Status">
{{if .Gesperrt}}
<span class="status">gesperrt</span>
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
@@ -52,35 +68,60 @@
</tbody>
</table>
</div>
<div class="pagination">
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
<span>
{{if .Pagination.HasPrev}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Filter.Suche}}">
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
<button type="submit">&larr; Zurück</button>
</form>
{{end}}
{{if .Pagination.HasNext}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Filter.Suche}}">
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
<button type="submit">Weiter &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}}
<div class="table-scroll">
<div class="table-responsive">
<table>
<thead>
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>Zuletzt geprüft</th></tr>
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
</thead>
<tbody>
{{range .EigeneWerkzeuge}}
<tr>
<td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{.LetztePruefung}}</td>
<td data-label="Name"><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td data-label="Anbieter">{{.Anbieter}}</td>
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
<td data-label="Zuletzt geprüft">{{.LetztePruefung}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
</div>
</body>
</html>

View File

@@ -15,13 +15,22 @@
geschlossen wird.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
<button type="submit">Importieren</button>
<div class="form-section">
<div class="form-grid">
<div class="form-full">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Importieren</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

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