Compare commits

..

28 Commits

Author SHA1 Message Date
noroot
b0d6b00045 fix: Migration 0021 crashte in Produktion — falsche Rollen-Annahme
v0.38.0 verursachte einen ~3-minütigen Ausfall: Migration 0021 nahm an,
die Anwendung verbinde überall als Postgres-Superuser "postgres" (nur
lokal via sudo geprüft, nicht die tatsächliche Produktions-DATABASE_URL)
und versuchte dort eine neue Rolle anzulegen - production verbindet
aber über die nicht-privilegierte, tabellenbesitzende Rolle "deklarix"
ohne CREATEROLE, das INSERT/CREATE ROLE schlug fehl und der Dienst
crash-loopte im "dirty migration"-Zustand. Kein Datenverlust (Postgres
hat die DDL-Transaktion selbst zurückgerollt), Dienst wurde auf v0.37.0
zurückgestuft und stabilisiert.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:47:07 +02:00
noroot
96ac5d6b59 refactor: unbenutzte View-Felder entfernen (ID auf drei Templates nie referenziert)
Ein systematischer Audit nach demselben Muster wie beim Fragebogen-
Antworten-Bug fand drei Felder, die vom Handler befüllt, aber im
zugehörigen Template nie referenziert werden: antragDetailData.ID
(kein Formular auf der Seite braucht sie), anforderungView.ID (die
Beschreibung reicht als lesbare Anforderung, die interne Regel-ID ist
kein Nutzerinhalt), betreiberAccountDetailData.AccountID (die
Account-Detailseite ist rein lesend, kein Formular zeigt darauf).
Anders als der Fragebogen-Antworten-Bug ist hier keine für Nutzer
relevante Information verloren gegangen — die Felder waren schlicht
totes Gewicht, deshalb entfernt statt eine Anzeige dafür zu erfinden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 21:07:00 +02:00
noroot
29961f5657 fix: Fragebogen-Antworten auf der Antrag-/Fall-Detailseite anzeigen
Die tatsächlich gegebenen Fragebogen-Antworten (B-Fragen zur Datenklasse,
C-Fragen zur KI-VO-Einstufung, D-Werkzeugangaben) wurden bisher nirgends
angezeigt — weder dem antragstellenden Mitarbeiter noch der Fachebene
beim Entscheiden, obwohl der Code-Kommentar an handleAntragDetail schon
"zeigt einen Antrag mit allen Antworten" versprach. Nur die daraus
abgeleitete Bewertung war sichtbar, nicht die Grundlage, auf der sie
beruht — im Audit nicht nachvollziehbar (siehe CLAUDE.md, Grundregel)
und für die Fachebene beim Entscheiden nicht praktikabel.

antwortenAnzeige() baut aus den rohen JSON-Antworten eine lesbare Liste
mit denselben Fragen-Labels wie im Fragebogen selbst — jetzt sowohl auf
GET /antraege/{id} (Ebene 2) als auch GET /faelle/{id} (Ebene 3)
sichtbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 18:11:25 +02:00
noroot
3ec57f2786 fix: admin bekommt Fachebene-Rechte (Posteingang, Entscheiden, Register, Wiedervorlage)
Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat
nur einen admin-Login und konnte damit bislang keinen einzigen
eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur
verantwortlicher/pruefer durchließ. Die Spezifikation will admin+
KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md,
Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder
zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch
dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht
in fachebene_handlers.go). pruefer bleibt unverändert nur lesend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:58:06 +02:00
noroot
6884f28109 feat: CSV-Import für Mitarbeiter
Schließt den zweiten Punkt der geplanten Onboarding-Reihenfolge. Admin
lädt eine CSV mit Spalten email (Pflicht) und role (optional, Standard
mitarbeiter) unter GET /verwaltung/nutzer/import hoch. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt und nur auf der Ergebnisseite direkt nach
dem Import angezeigt, danach nirgendwo mehr abrufbar. Kein
Alles-oder-nichts-Import: eine ungültige Zeile blockiert nicht die
übrigen, jede Zeile bekommt ihr eigenes Ergebnis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:52:02 +02:00
noroot
b91a58ccba feat: Einladungslink für die Mitarbeiter-Selbstanmeldung
Schließt den ersten Punkt der geplanten Onboarding-Reihenfolge (siehe
CLAUDE.md): ein Admin sieht unter GET /verwaltung/einladung einen
Sammellink (account.einladung_token, Migration 0013), den beliebig
viele Mitarbeitende nutzen können, um sich selbst mit eigenem Passwort
anzumelden (GET/POST /einladung/{token}, öffentlich, Rolle
mitarbeiter). Kein Ablaufdatum, aber per Admin jederzeit erneuerbar —
das macht den alten Link sofort ungültig, falls er versehentlich
außerhalb des Unternehmens geteilt wurde. Keine E-Mail-Bestätigung
(bewusst konsistent mit dem Rest des Produkts, das noch keine
E-Mail-Infrastruktur hat).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:41:36 +02:00
noroot
eb99891e38 feat: eigene Werkzeugkatalog-Einträge für Mandanten
Ein Mandant kann jetzt zusätzlich zum Sperren zentraler Katalogeinträge
auch eigene, nur für sich sichtbare Werkzeuge anlegen/bearbeiten/
löschen (GET/POST /verwaltung/werkzeuge/neu bzw. /{id}, POST
/{id}/loeschen — werkzeug.account_id = eigener Account). Nutzt
dasselbe Formular wie der zentrale Katalog des Betreibers
(werkzeugFormData/betreiber-werkzeug-form.html, ein neues ActionBase-
Feld unterscheidet die Ziel-URL); ein zentraler oder fremder Eintrag
bleibt über diese Route unerreichbar (404). Schließt die letzte
dokumentierte Lücke bei "eigene Werkzeug-Freigaben/-Sperrungen" (Ebene 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:23:43 +02:00
noroot
36af1bf288 feat: responsives Frontend — Seitenleiste ab Desktop, Tabellen-Styling
Nav wird jetzt für alle Seiten aus einer einzigen Stelle
(layout.html-Block) gerendert: auf dem Handy ein per Checkbox-Hack
aufklappbares Menü (kein JavaScript), ab 960px Breite per CSS in eine
permanente, dunkle Seitenleiste im enconf-Look verwandelt — dieselbe
Markup-Struktur bedient beide Layouts, kein separates Desktop-Template.
<details>/<summary> wurde bewusst NICHT verwendet: aktuelle
Chromium-Versionen steuern dessen Auf-/Zuklapp-Zustand über eine
CSS-resistente interne Animation (auch mit !important nicht
überschreibbar), per Headless-Screenshot verifiziert.

Zusätzlich: Tabellen (vorher komplett ungestylt, betraf v. a. das
Register) haben jetzt echte Gestaltung inkl. horizontal scrollbarem
Wrapper für schmale Bildschirme, Listenkarten ohne Link-Wrapper
(Nutzer-/Abteilungsliste) bekommen dieselbe Kartenoptik wie
verlinkte Einträge, und destruktive Aktionen (Löschen/Deaktivieren)
sind jetzt visuell von primären Aktionen unterschieden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:06:14 +02:00
109 changed files with 8870 additions and 673 deletions

886
CLAUDE.md
View File

@@ -39,8 +39,8 @@ Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/ Zweckerkennung aus dem Freitext vorgesehen (die zwölf Zweck-Kategorien
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung erkennen, siehe `gueltigeZwecke` weiter unten), nie für die Einstufung
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt. selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
--- ---
@@ -67,7 +67,10 @@ Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber` dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
ist der Nachfolger dessen, was früher (vor dem Produktwechsel) ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
`admin` hieß. `admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche. je Handler), nicht als grob unterschiedene Seitenbereiche.
@@ -98,20 +101,61 @@ reiner Anzeige-Screen reicht nicht):
reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese
Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und
unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit. unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit.
- **Eigene Werkzeug-Sperrungen** (`internal/web/mandant_werkzeug_handlers.go`, - **Eigene Werkzeug-Sperrungen und -Einträge** (`internal/web/mandant_werkzeug_handlers.go`,
`GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen `GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen
Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne
den zentralen Katalog selbst zu verändern — das bleibt Sache der den zentralen Katalog selbst zu verändern — das bleibt Sache der
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). Zusätzlich kann
ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/
bearbeiten/löschen (`GET/POST /verwaltung/werkzeuge/neu`, `GET/POST
/verwaltung/werkzeuge/{id}`, `POST /verwaltung/werkzeuge/{id}/loeschen`
`werkzeug.account_id` = der eigene Account). Nutzt dasselbe Formular
wie der zentrale Katalog (`werkzeugFormData`/`betreiber-werkzeug-form.html`,
ein `ActionBase`-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL)
— ein zentraler oder fremder Eintrag ist über diese Route nicht
erreichbar (404).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, eigene **Support-Login (Ebene 5, `betreiber`) ist umgesetzt** — auf
Werkzeug-EINTRÄGE eines Mandanten (nur Sperrungen zentraler Einträge `GET /betreiber/accounts/{id}` kann sich der Betreiber über
sind umgesetzt, `account_id`-gesetzte eigene Katalogeinträge fehlen `POST .../nutzer/{userID}/anmelden-als` als ein aktiver Kunden-Nutzer
noch), Rechnungsdaten/Abrechnung (kein Abo-System, bewusst "Nicht bauen anmelden, ohne dessen Passwort zu kennen (`handleBetreiberLoginAls` in
v1"), Account-Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts `betreiber_handlers.go`, `store.CreateImpersonatedSession`, Migration
nur lesend an — Bearbeiten/Sperren hängt an der noch nicht getroffenen 0014). Nicht für deaktivierte Nutzer möglich. Die neue Sitzung ersetzt
Abrechnungs-/Freischaltungs-Architektur, siehe Offene Punkte: die eigene Betreiber-Sitzung (kein Sitzungs-Stack/"Zurück zum
`account.verified` wurde beim Produktwechsel sogar entfernt). Betreiber" — der Betreiber meldet sich danach mit den eigenen
Zugangsdaten neu an). Jede Nutzung erzeugt einen `audit_log`-Eintrag
(Action `betreiber_login_als_nutzer`); während der gesamten Sitzung
zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist,
im Kontext eines fremden Kontos zu handeln.
**Firmen-CRUD für den Betreiber ist umgesetzt:** `GET/POST
/betreiber/accounts/neu` legt eine Firma samt erstem `admin`-Login an
(dasselbe Ergebnis wie die öffentliche Registrierung, nur vom Betreiber
ausgelöst — z. B. für vertriebsunterstütztes Onboarding oder Testkonten),
`POST /betreiber/accounts/{id}/umbenennen` korrigiert den Firmennamen
(`store.UpdateAccount`). Jede Firmenanlage erzeugt einen
`audit_log`-Eintrag. Bewusst **kein** Löschen — ein Hard-Delete würde
gegen die Fremdschlüssel aus `antrag`/`app_user`/`audit_log` laufen und
Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
noch nicht, siehe unten).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, das
Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche
Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes,
externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**.
Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte
Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum),
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst
(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage
Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der
Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts
durch den Betreiber hängt weiter an dieser (jetzt kleineren)
Subskriptions-Architektur (siehe Offene Punkte: `account.verified`
wurde beim Produktwechsel sogar entfernt).
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird **Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
@@ -207,11 +251,151 @@ Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
zentralen Katalog zu verändern. zentralen Katalog zu verändern.
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede **Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt- Zusicherung im Katalog (AVV verfügbar, Verarbeitungsländer, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
nicht in der UI umgesetzt, siehe Offene Punkte). `betreiber_werkzeug_handlers.go`, CSS-Klassen `status-niedrig`/
`-mittel`/`-hoch`, dieselben Farbtoken wie die KI-VO-Risikoampel):
**aktuell** (grün, < 150 Tage), **bald fällig** (gelb, 150180 Tage —
dasselbe 30-Tage-Vorwarnfenster wie bei der Wiedervorlage), **abgelaufen**
(rot, > 180 Tage).
`aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet
"vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte
Zusicherung: sofortige Löschung", z. B. DeepL) unterschieden. Vorher
war die Spalte `NOT NULL DEFAULT 0`, wodurch beide Fälle identisch
aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
"0 Tage".
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
Vokabular statt Freitext: das Formular zeigt Checkboxen statt eines
Freitextfelds (`gueltigeZwecke` in `betreiber_werkzeug_handlers.go`,
serverseitig validiert via `istGueltigerZweck`). Grund: ein künftiger
automatischer Zweckabgleich (Bewertungslogik Schritt 3, weiterhin nicht
gebaut) würde an inkonsistentem Freitext ("Text" statt
"Textgenerierung") scheitern. Am 2026-08-31 von sechs auf zwölf
Kategorien erweitert, nachdem der Nutzer zurecht einwandte, dass sechs
Kategorien die tatsächliche Bandbreite an Einsatzgebieten nicht
abdecken: Textgenerierung, Übersetzung, Code, Bild, Transkription,
Recherche, **Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten**.
Es gibt keinen einheitlichen Branchenstandard für KI-Funktions-
kategorien (regulatorische Taxonomien wie die KI-VO selbst oder OECD/
NIST klassifizieren nach Risiko, nicht nach Funktion; Marktplatz-
Kategorien wie bei G2 sind Marketing, kein Standard) — diese zwölf
sind Deklarix' eigene, bewusst begrenzte Liste für den künftigen
Zweckabgleich, keine allgemeine KI-Taxonomie.
Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
AWS bei DeepL) als eigenes, durchsuchbares Feld sichtbar statt nur als
Freitext in `einschraenkungen` — das ist oft der Grund, warum ein
scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
als Tabelle (Name, Anbieter, Verarbeitungsländer, DPF, AVV, Training aus,
Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
**Filter & Paginierung** (seit 2026-08-31, `pagination.go` +
`werkzeugFilter` in `betreiber_werkzeug_handlers.go`): beide
Katalog-Tabellen haben eine Filterleiste (Freitext über Name/Anbieter/
Verarbeitungsländer, Dropdown über die zwölf Zweck-Kategorien) und
serverseitige Paginierung (20 Einträge/Seite, `page`-Query-Parameter).
Alles über ein einziges GET-Formular, ohne JavaScript — Prev/Next-
Buttons setzen `page` per `<button type="submit" name="page"
value="...">`, damit q/zweck beim Blättern erhalten bleiben. Auf der
Mandanten-Seite (die pro Zeile zusätzlich Sperren/Entsperren-Formulare
hat) sind Filter und Paginierungs-Buttons bewusst in getrennte
`<form>`-Elemente aufgeteilt statt in eines um die ganze Tabelle —
verschachtelte `<form>`-Elemente sind ungültiges HTML, die
Paginierungs-Buttons tragen `q`/`zweck` deshalb als Hidden-Felder
nach. `pagination.go` ist bewusst generisch (Go-Generics,
`paginate[T any]`) für Wiederverwendung in weiteren Listen (Register,
Fälle, ...) — bisher nur auf den Werkzeugkatalog angewendet, das war
die von den beiden am dringendsten wachsende Tabelle.
Der zentrale Katalog ist aktuell mit 20 recherchierten Einträgen
befüllt (u. a. ChatGPT Enterprise, Microsoft 365 Copilot, Claude for
Work, Claude Code, GitHub Copilot Business, DeepL Pro/API, Google
Gemini für Google Workspace, Le Chat Enterprise, Perplexity Enterprise
Pro, Adobe Firefly Enterprise, Otter.ai Business, Notion AI, Grammarly
Business, Midjourney, Julius AI, Synthesia, ElevenLabs, Zendesk AI,
Canva Magic Studio, Zapier) — deckt damit alle zwölf Zweck-Kategorien mit
mindestens einem Eintrag ab. **Claude Code ist bewusst ein eigener
Eintrag neben Claude for Work**, kein Duplikat: es läuft lokal auf
Entwicklerrechnern mit direktem Zugriff auf Dateien/Codebasen und
speichert Sitzungsprotokolle lokal im Klartext (bis zu 30 Tage,
konfigurierbar) — ein zusätzliches lokales Aufbewahrungsrisiko, das bei
einer reinen Web-Chat-Anwendung wie Claude for Work nicht existiert,
obwohl beide über dieselben Commercial Terms/DPA laufen. Jede
Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
und zwar auch dann, wenn keine Quelle eine positive Zusicherung
hergibt: Synthesia und Zapier haben `training_standard`/
`training_opt_out` bewusst NICHT gesetzt, weil keine belegte Aussage
zum Trainingsausschluss gefunden wurde (nicht mit "trainiert
nachweislich" verwechseln — es ist ein "wissen wir nicht, deshalb
keine Haken"). Mehrere Einträge sind bewusst mit kritischen
Einschränkungen versehen statt schöngefärbt, das ist die vorgesehene
Funktion des Katalogs: Adobe Firefly (Trainingsausschluss ist nicht
Standard), Otter.ai (Training auf de-identifizierten Aufnahmen,
laufende Rechtsstreitigkeiten), **Midjourney (kein AVV, keine
EU-Datenresidenz, unbegrenzte Speicherung inkl. Training — laut
CLAUDE.md-Leitplanken für personenbezogene/vertrauliche Daten nicht
geeignet, absichtlich als Negativbeispiel im Katalog, nicht
weggelassen)**.
**Schema-Lücke behoben (2026-08-31, Migration 0016):** der grobe
`verarbeitungsort`-Eimer (`EU`/`USA`/`gemischt`/`on-prem`) wurde durch
`verarbeitungslaender TEXT[]` ersetzt — echte Länder statt Buckets.
Grund: "USA" war schon immer ein Drittland im Sinne der DSGVO (Art. 44
ff.) wie jedes andere auch, das Wort "Drittland" für einen fünften
Bucket-Wert wäre also verwirrend gewesen, nicht erhellend. Das Formular
bietet dafür eine echte Weltländerliste (`weltLaenderliste` in
`laenderliste.go`, `<select multiple>`, Mehrfachauswahl per Strg/Cmd-
Klick — kein JavaScript nötig) plus zwei Pseudo-Einträge ("EU-Region
(kein einzelnes Land benannt)" für Anbieter, die nur eine Region statt
eines Staates zusichern, z. B. Microsofts EU Data Boundary; "On-Premise
(selbst gehostet)"). Der harte Filter `eu_verarbeitung`
(`internal/rules/evaluate.go`, `alleLaenderInEUEWR`) verlangt jetzt,
dass ALLE genannten Länder EU/EWR-Mitgliedstaaten sind — eine leere
Liste gilt als nicht erfüllt, nicht als Freifahrtschein. Zusätzlich neues
Feld `dpf_zertifiziert` (Checkbox "EU-US Data Privacy Framework
zertifiziert"): DPF ist eine schmalere, gerichtlich schon zweimal
gekippte Rechtsgrundlage (Safe Harbor, Privacy Shield) für USA-Transfers,
bisher nur als Fließtext in "Einschränkungen" erwähnt, jetzt strukturiert
und damit künftig filterbar. Alle 19 Katalogeinträge wurden mit den
tatsächlichen, bereits recherchierten Ländern nachgepflegt (z. B.
Mistral → Frankreich, DeepL → Deutschland, Synthesia → Irland, Claude
for Work → USA) statt der pauschalen alten Buckets.
Das öffnet jetzt auch DeepSeek (Verarbeitung in China) für einen
künftigen Katalogeintrag — die Sperre lag ausschließlich am alten
Wertebereich, nicht an einer fachlichen Entscheidung.
**`dpf_zertifiziert` recherchiert (2026-09-01), Offener Punkt erledigt.**
Für alle 20 Einträge wurde gezielt gegen das offizielle Register
(dataprivacyframework.gov, per Participant-ID einzeln nachvollziehbar)
geprüft — nicht gegen die JS-Suchseite selbst (nicht scrapebar), sondern
gegen das dahinterliegende, von ihr selbst genutzte API. Ergebnis: 12
zertifiziert (Microsoft 365 Copilot, GitHub Copilot Business, Google
Gemini, Perplexity Enterprise Pro, Adobe Firefly, Otter.ai Business,
Notion AI, Grammarly Business, ElevenLabs, Zendesk AI, Canva Magic
Studio, Zapier), 5 recherchiert aber nicht im Register gefunden
(ChatGPT Enterprise, Claude for Work, Claude Code, Midjourney, Julius
AI — `false` heißt hier jetzt "geprüft, kein Eintrag gefunden", nicht
mehr "nicht recherchiert"), 3 nicht anwendbar (DeepL, Le Chat/Mistral,
Synthesia — reine EU-Verarbeitung ohne US-Rechtsträger im Register, DPF
regelt EU→USA-Transfers). Besonderheiten als `einschraenkungen`-Eintrag
je Werkzeug festgehalten: Perplexity hat nur EU-US/UK-, keine
Swiss-US-Zertifizierung; Notion AIs Zertifizierung ist als "Active" mit
bereits abgelaufenem Datum in Re-Zertifizierung; Grammarly ist im
Register unter dem fusionierten Rechtsträger "Superhuman Platform Inc."
gelistet, nicht mehr unter "Grammarly, Inc.".
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des `CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
@@ -228,8 +412,8 @@ bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
Werkzeuge werden im Ergebnis mit Begründung gezeigt. Werkzeuge werden im Ergebnis mit Begründung gezeigt.
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten 3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche) (die zwölf Kategorien aus `gueltigeZwecke`, siehe Werkzeugkatalog
und gegen `geeignete_zwecke` filtern. weiter unten) und gegen `geeignete_zwecke` filtern.
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung, 4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
Prüfung. Prüfung.
@@ -281,13 +465,16 @@ Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt** **Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene (`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst 3). Zum Zeitpunkt dieser Umsetzung gab es noch keine E-Mail-Infrastruktur
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort, (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 wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsländern. Keine
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount` Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
@@ -307,6 +494,358 @@ 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.
---
## 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 ## Onboarding
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular **Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
@@ -319,12 +858,28 @@ leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis (noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist). Ebene 5/Betreiber-Pflege gebaut ist).
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink **Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV- **erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma- jederzeit erneuerbar [macht den alten Link sofort ungültig].
Registrierung. `GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
Einladung.), (2) ~~CSV-Import~~**erledigt** (`internal/web/csv_import_handlers.go`,
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`],
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht** **Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
@@ -332,6 +887,179 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
--- ---
## Frontend
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
buchbarer Service werden soll): **für Browser UND Mobile optimiert,
und jede Entität im Datenmodell muss über das Frontend steuerbar sein**
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
Stack bleibt `html/template` + htmx, kein React/Vue/SPA (siehe
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
**Navigation** (`internal/web/templates/layout.html`, `"nav"`-Block,
einmal definiert und von jeder Seite eingebunden — Änderungen hier
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
`<label for=...>` als Klick-Ziel) klappt auf dem Handy ein Menü auf,
ganz ohne JavaScript. **Nicht** das naheliegendere `<details>`/`<summary>`
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
Zustand über eine interne, mit CSS (auch nicht mit `!important`)
nicht überschreibbare Animation; ein Versuch, `.app-nav { display:
flex !important }` bei geschlossenem `<details>` zu erzwingen, bleibt
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
960px Breite (`@media (min-width: 960px)`, `app.css`) wird exakt
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
Seitenleiste (Farb-Tokens aus `design/enterprise.css` übernommen) —
kein separates Desktop-Template.
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
Scrollen auf dem Handy statt gequetschter Spalten). Seit 2026-08-31 hat
`.table-scroll` zusätzlich einen rechten Fade-Rand (`mask-image`) —
ohne den sah eine am Fensterrand abgeschnittene breite Tabelle wie ein
Rendering-Fehler aus statt wie "hier geht's mit Scrollen weiter" (per
Screenshot am Werkzeugkatalog beobachtet: die letzten zwei Spalten
waren nur als einzelne Buchstaben sichtbar, ganz ohne Hinweis auf mehr
Inhalt).
**Formulare** (`.form-card`/`.form-section`/`.form-grid`/`.chip-group`
in `app.css`, seit 2026-08-31, erstmals angewendet auf
`betreiber_werkzeug_form.html`): vorher war jedes Formular eine flache
Spalte aus `<label>`+`<input>` ohne Gruppierung — bei einem Formular
mit über einem Dutzend Feldern (Werkzeug anlegen) wirkte das "klobig,
einfach nur untereinander" (O-Ton Nutzer). Jetzt: `.form-card` (weiße
Karte mit Rahmen/Schatten, Ant-Design-Tokens aus `design/enterprise.css`
übernommen) enthält mehrere `<fieldset class="form-section">`
(thematische Abschnitte mit Titel, per `border-top` getrennt), darin
`.form-grid` (2 Spalten ab 640px für kurze Felder wie Name/Anbieter
nebeneinander, `.form-full` erzwingt volle Breite für lange Felder).
Checkbox-Gruppen mit überschaubar vielen Optionen (Zusicherungen, die
zwölf Zwecke) sind `.chip-group` — abgerundete Pillen statt einer
langen Untereinander-Liste, ausgewählte Chips werden über
`:has(input:checked)` blau hervorgehoben (kein JavaScript). Ein
Löschen-Button steht nicht mehr einfach unter dem Formular, sondern in
einer abgesetzten `.form-gefahrenzone`-Fußzeile der Karte. Dieselben
Klassen sind bewusst generisch (nicht an ein Formular gebunden) — noch
nicht auf andere Formulare (Login/Registrierung, Fragebogen, übrige
Admin-Formulare) angewendet, das wäre der nächste Schritt, falls
gewünscht. Beim Umbau auffällig gewordener, vorbestehender Bug: das
Lösch-Formular in `betreiber_werkzeug_form.html` war fest auf
`/betreiber/werkzeuge/{id}/loeschen` verdrahtet, obwohl dieselbe
Vorlage auch vom Mandanten-Formular (`/verwaltung/werkzeuge/...`)
genutzt wird — ein Mandant hätte beim Löschen eines eigenen Werkzeugs
403 bekommen. Jetzt `{{.ActionBase}}/{{.ID}}/loeschen`, wie die
Formular-`action` selbst.
**Typografie-Nachbesserung (2026-08-31):** Nutzer-Feedback nach dem
ersten Formular-Redesign: Überschriften wirkten zu klein und zu dicht
am Rand. Ursache für Letzteres: `<legend>` in `<fieldset
class="form-section">` positioniert sich per Spec an der Border-Box
des Fieldsets, nicht an dessen Padding-Box — ohne eigenes Padding sitzt
die Abschnittsüberschrift optisch enger am Kartenrand als der übrige,
per `.form-section { padding: 20px }` eingerückte Inhalt. `.form-section
> legend` hat jetzt selbst `padding: 0 0 16px 0` und eine größere
Schrift (1.0625rem/700 statt 0.9375rem/600). Gleichzeitig `h2`
(1.25rem) und `h3` (1.0625rem) erstmals explizit gesetzt (vorher reiner
Browser-Default) und `.fragebogen legend` auf dieselbe Gewichtsstufe
wie die Formular-Abschnitte angehoben (600/1rem statt 500/0.9375rem) —
für eine einheitliche Überschriften-Hierarchie statt Zufallsgrößen je
nach UA-Stylesheet.
**enconf-Patterns übertragen (2026-08-31):** enconf (Schwesterprojekt)
ist React + Ant Design (`ProTable`/`StatCard`/`PageHeader`-Komponenten)
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die
*Muster* wurden als reines CSS/HTML nachgebaut (recherchiert über die
Architect-RAG-Suche, `ac_search_code_global`, da das enconf-Repo selbst
außerhalb des Arbeitsverzeichnisses liegt):
- **`.table-responsive`** (ersetzt `.table-scroll` schrittweise) —
enconfs `ProTable.tsx` schaltet auf schmalen Screens von Tabelle auf
eine Card-Liste um (`renderMobileCard` via `Grid.useBreakpoint()`),
statt horizontal zu scrollen. Nachgebaut rein in CSS: unter 640px
werden `<tr>` zu Karten, `<td>` zu Label/Wert-Zeilen (`content:
attr(data-label)` im `::before`) — jedes `<td>` braucht dafür ein
`data-label="Spaltenname"`-Attribut. Ab 640px normale Tabelle mit
Scroll-Fade als Sicherheitsnetz. Bisher angewendet auf beide
Werkzeugkatalog-Tabellen; `.table-scroll` (nur Scroll-Fade, keine
Card-Umschaltung) bleibt für noch nicht migrierte Tabellen (Register,
Nutzer-Import-Ergebnis) bestehen.
- **`.page-header`** (Titel+Unterzeile links, primäre Aktion rechts,
stapelt auf Mobile) — ersetzt einen nackten `<h1>` mit einem Link
irgendwo unten auf der Seite. Bisher auf den Werkzeugkatalog
angewendet (Betreiber + Mandant, inkl. "Eigene Werkzeuge"-Abschnitt).
- **`.stat-cards`/`.stat-card`** — Kennzahl-Kacheln mit farbigem oberem
Akzentrand (blau normal, gelb/rot nur wenn der Wert > 0 ist — keine
Farbe ohne Grund) statt einfacher Text-Links; `.stat-card-action` für
die primäre Handlungs-Kachel (z. B. "Antrag stellen"). Kein
Icon-Set/keine Icon-Bibliothek eingeführt (enconf nutzt
`@ant-design/icons`, das ist React-spezifisch) — bewusste
Vereinfachung. Angewendet auf die Startseite (`index.html`).
**Ausgerollt (2026-08-31):** Register, Nutzerverwaltung, Abteilungen,
Fälle-Posteingang, Betreiber-Dashboard.
- **Register** (`register_liste.html`) und **Fälle-Posteingang**
(`faelle_liste.html`) haben jetzt `.page-header` + Freitext-Filter
(`q`) + Paginierung. Register zusätzlich `.table-responsive` (war
vorher `.table-scroll`) mit `data-label` je `<td>`.
- **Nutzerverwaltung** (`nutzer_liste.html`) hat `.page-header` +
Freitext-Filter (E-Mail/Rolle) + Paginierung — blieb `.beitraege-liste`
(schon Karten-artig), keine `.table-responsive`-Umstellung nötig.
- **Abteilungen** (`abteilung_liste.html`) bekam nur `.page-header`
bewusst ohne Paginierung, Abteilungslisten sind typischerweise klein
(Handvoll Stammdaten pro Mandant).
- **Betreiber-Dashboard** (`betreiber_dashboard.html`) von
`.admin-kacheln` auf `.stat-cards` umgestellt (analog zu `index.html`).
Neue gemeinsame Helfer in `pagination.go`: `matchesQuery(q string,
felder ...string) bool` — eine einzige Freitext-Filter-Basis für alle
Listen-Seiten, damit nicht jede ihre eigene Such-Logik erfindet.
**Beim Ausrollen gefundener, bereits live gewesener Bug:** `.filter-bar`
direkt auf einem `<form>`-Element (statt auf einem `<div>` innerhalb
des Formulars) verlor gegen die allgemeine `form { flex-direction:
column }`-Regel — beide Selektoren haben für die Property
`flex-direction` dieselbe Spezifität, aber nur `form` setzte sie
explizit, `.filter-bar` überließ sie dem Initialwert. Ergebnis: die
Filterleiste stapelte senkrecht statt in einer Zeile. Betraf bereits
die im vorigen Schritt ausgelieferte Mandanten-Werkzeugkatalog-Seite.
Behoben durch explizites `flex-direction: row` in `.filter-bar` selbst
— jetzt unabhängig davon robust, ob die Klasse auf einem `<form>` oder
einem `<div>` sitzt.
**Ebenfalls ausgerollt (2026-08-31, zweite Runde):** Betreiber-Accounts
(`.page-header` + Filter über Firmenname + Paginierung), Betreiber-
Audit-Log (`.page-header` + Filter über Aktion/Zieltyp/Details +
Paginierung), "Meine Anträge" (`.page-header` + Filter über Titel/
Status/Häufigkeit + Paginierung), Wiedervorlage (nur `.page-header`,
bewusst ohne Paginierung — natürlich begrenzt auf "braucht gerade
Aufmerksamkeit").
**Bekannte Einschränkung beim Audit-Log:** `ListAuditLog` holt weiterhin
nur die neuesten `auditLogFetchLimit` (1000) Einträge aus der DB, dann
wird in Go gefiltert/paginiert (`auditLogFetchLimit`-Konstante in
`betreiber_handlers.go`) — keine echte DB-seitige Offset-Paginierung.
Für ein potenziell sehr groß werdendes, append-only Audit-Log ist das
mittelfristig nicht die saubere Lösung (Einträge jenseits der 1000
neuesten sind über die Such-/Paginierungs-UI nicht erreichbar), aber
proportional zum aktuellen Datenvolumen und konsistent mit dem
In-Memory-Paginierungsmuster der übrigen Listen. Bei echtem Bedarf:
`store.ListAuditLog` um `offset`/echtes SQL-`LIMIT`/`OFFSET` erweitern.
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
authentifiziert abrufen, `/static/...`-Pfade im HTML auf eine absolute
`http://localhost:<port>/static/...`-URL umschreiben (Assets brauchen
kein Auth-Cookie), dann `chromium --headless --disable-gpu --no-sandbox
--window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html`
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
screenshotten. Für einen Menü-Aufklapp-Zustand: `checked`-Attribut
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
---
## Projektstruktur ## Projektstruktur
``` ```
@@ -346,7 +1074,10 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
│ └── web/ # Handler, Templates │ └── web/ # Handler, Templates
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen) ├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/ ├── design/
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot) │ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
│ (Farben/Radius/Schatten), nicht direkt eingebunden
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
├── packaging/ ├── packaging/
│ └── DEBIAN/ │ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template │ └── control.tmpl # .deb Package-Control-Template
@@ -455,12 +1186,22 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und (`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.) 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 Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit Nutzung einzelner Personen auswertet), Konzernstrukturen mit Vererbung,
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische mandantenspezifische Regelwerke, SAML, Schnittstellen zu Fremdsystemen.
Regelwerke, Workflow-Designer, 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).
--- ---
@@ -663,39 +1404,52 @@ journalctl -u deklarix -f
## Offene Punkte ## Offene Punkte
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil** - ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich 2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt nötig.
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten - ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu (`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
bleiben, aber die Ableitung kann sich noch ändern. eingeschlossen.
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ — - ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. **erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
Weiterhin offen: ob die drei Varianten fachlich vollständig sind die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
(siehe `rules/OPEN.md`, Punkt 3). ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
- **Löschfristen je Datenklasse** (konkrete Tageswerte für - ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt. **bestätigt**, `intern` bleibt die konservative Standardannahme.
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)** - **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
— die Frontend-Spezifikation fordert das explizit ("nicht in der (`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
Anwendungslogik"), aktuell ist Isolation ausschließlich `kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS `kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
ist ein bewusst noch nicht getroffener Architektur-Entscheid — — weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen Rückmeldung dazu bisher eingeholt.
entstehen, die sonst nachträglich migriert werden müssten. - ~~Löschfristen je Datenklasse~~ — **erledigt 2026-09-01**, siehe
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die „Löschfristen je Datenklasse" weiter oben: pro Mandant einstellbar
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig statt hartkodiert, da die DSGVO selbst keine festen Fristen nennt.
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt - ~~Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)~~
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder — **Code/Migration/Tests erledigt 2026-09-01, Produktivbetrieb noch
zwei `app_user`-Zeilen für dieselbe Person? ausstehend** (siehe „Row-Level-Security" weiter oben für den vollen
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die Stand und was auf dem Produktivserver noch manuell nachzuziehen ist).
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET - ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu` gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
.../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`. Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
[404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma- `fachebene_handlers.go`). Grund: ohne das könnte eine frisch
Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist registrierte Firma mit nur einem `admin`-Login keinen einzigen
inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
pflegt. Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
- ~~Zentraler Werkzeugkatalog muss noch befüllt werden~~ — **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. - Markenanmeldung Deklarix, sobald erster Kunde zahlt.

View File

@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/netcell-it/deklarix/internal/mail"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web" "github.com/netcell-it/deklarix/internal/web"
@@ -21,7 +22,19 @@ func main() {
log.Fatalf("migrate: %v", err) 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 { if err != nil {
log.Fatalf("open store: %v", err) 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)", log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir) 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{ server, err := web.NewServer(db, web.Regelwerk{
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen, Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
}) }, mailer)
if err != nil { if err != nil {
log.Fatalf("web server: %v", err) 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 // statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
// unabhängig von internal/store und für sich allein testbar. // unabhängig von internal/store und für sich allein testbar.
type WerkzeugEigenschaften struct { type WerkzeugEigenschaften struct {
AVVVerfuegbar bool AVVVerfuegbar bool
Verarbeitungsort string // Verarbeitungslaender nennt die tatsächlichen Länder statt eines
TrainingOptOut bool // groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
TrainingStandard bool // eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
AufbewahrungTage int // 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 // ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
@@ -198,17 +237,30 @@ type WerkzeugEigenschaften struct {
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung, // Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage // DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das // vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich // Werkzeug selbst.
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb //
// ebenfalls noch nicht hart gefiltert. // loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool { // 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 { switch anforderungID {
case "avv_erforderlich": case "avv_erforderlich":
return w.AVVVerfuegbar return w.AVVVerfuegbar
case "eu_verarbeitung": case "eu_verarbeitung":
return w.Verarbeitungsort == "EU" return alleLaenderInEUEWR(w.Verarbeitungslaender)
case "kein_training_auf_eingabe": case "kein_training_auf_eingabe":
return w.TrainingStandard return w.TrainingStandard
case "loeschfrist_max_tage":
if loeschfristMaxTage == nil {
return true
}
if w.AufbewahrungTage == nil {
return false
}
return *w.AufbewahrungTage <= *loeschfristMaxTage
default: default:
return true return true
} }
@@ -230,12 +282,13 @@ type WerkzeugKandidat struct {
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine // FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene // Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
// Werkzeug fest, welche Anforderungen fehlten. // Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) { // ErfuelltAnforderung.
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
for _, k := range kandidaten { for _, k := range kandidaten {
var fehlend []string var fehlend []string
for _, reqID := range anforderungIDs { for _, reqID := range anforderungIDs {
if !ErfuelltAnforderung(reqID, k.Eigenschaften) { if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
fehlend = append(fehlend, reqID) fehlend = append(fehlend, reqID)
} }
} }

View File

@@ -150,26 +150,62 @@ func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
} }
func TestErfuelltAnforderungAVV(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") 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") 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) { 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") t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
} }
} }
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) { func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
kandidaten := []rules.WerkzeugKandidat{ kandidaten := []rules.WerkzeugKandidat{
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}}, {ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}}, {ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
} }
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}) zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}, nil)
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" { if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig) t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
} }
@@ -183,7 +219,7 @@ func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) { func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}} 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 { if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen) 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. // CreateAbteilung legt eine Abteilung für einen Mandanten an.
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) { func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
var a Abteilung var a Abteilung
err := s.Pool.QueryRow(ctx, ` err := s.db(ctx).QueryRow(ctx, `
INSERT INTO abteilung (account_id, name) VALUES ($1, $2) INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
RETURNING id, account_id, name, created_at RETURNING id, account_id, name, created_at
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt) `, 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, // ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
// alphabetisch — als Auswahlliste für den Fragebogen. // alphabetisch — als Auswahlliste für den Fragebogen.
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) { 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 SELECT id, account_id, name, created_at FROM abteilung
WHERE account_id = $1 ORDER BY name WHERE account_id = $1 ORDER BY name
`, accountID) `, accountID)
@@ -59,7 +59,7 @@ func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string)
// GetAbteilung liest eine Abteilung anhand ihrer ID. // GetAbteilung liest eine Abteilung anhand ihrer ID.
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) { func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
var a Abteilung 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 SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt) `, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) { 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 // DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
// angelegt). // angelegt).
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error { 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 { if err != nil {
return fmt.Errorf("store: delete abteilung: %w", err) return fmt.Errorf("store: delete abteilung: %w", err)
} }

View File

@@ -10,32 +10,59 @@ import (
) )
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung // Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account. // 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.
type Account struct { type Account struct {
ID string ID string
Name string Name string
CreatedAt time.Time EinladungToken string
CreatedAt time.Time
} }
// CreateAccount legt einen neuen Mandanten an. const accountColumns = `id, name, einladung_token, created_at`
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
func scanAccount(row interface {
Scan(dest ...any) error
}) (Account, error) {
var a Account var a Account
err := s.Pool.QueryRow(ctx, ` err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &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.db(ctx).QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1) INSERT INTO account (name) VALUES ($1)
RETURNING id, name, created_at RETURNING `+accountColumns,
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt) name,
)
a, err := scanAccount(row)
if err != nil { if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err) return Account{}, fmt.Errorf("store: create account: %w", err)
} }
return a, nil return a, nil
} }
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
// Betreiber-gestützten Anlage korrigieren).
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: update account: %w", err)
}
return a, nil
}
// GetAccount liest einen Mandanten anhand seiner ID. // GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
var a Account row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
err := s.Pool.QueryRow(ctx, ` a, err := scanAccount(row)
SELECT id, name, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound return Account{}, ErrNotFound
} }
@@ -45,12 +72,40 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
return a, nil return a, nil
} }
// GetAccountByEinladungToken liest den Mandanten zu einem
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
// (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.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
}
if err != nil {
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
}
return a, nil
}
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
// 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.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)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den // ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung). // Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, ` rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
SELECT id, name, created_at FROM account ORDER BY created_at DESC
`)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err) return nil, fmt.Errorf("store: list accounts: %w", err)
} }
@@ -58,8 +113,8 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
var out []Account var out []Account
for rows.Next() { for rows.Next() {
var a Account a, err := scanAccount(rows)
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil { if err != nil {
return nil, fmt.Errorf("store: scan account: %w", err) return nil, fmt.Errorf("store: scan account: %w", err)
} }
out = append(out, a) out = append(out, a)

View File

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

View File

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

View File

@@ -25,6 +25,89 @@ func TestAccountCRUD(t *testing.T) {
if got.Name != "Beispiel Agentur GmbH" { if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name) t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
} }
if got.EinladungToken == "" {
t.Error("expected a newly created account to have a non-empty EinladungToken")
}
}
func TestUpdateAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
if err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
if updated.Name != "Neuer Name GmbH" {
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Neuer Name GmbH" {
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
}
}
func TestUpdateAccountNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
if err != nil {
t.Fatalf("GetAccountByEinladungToken: %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken returned a different account")
}
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
}
}
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
alterToken := acc.EinladungToken
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
t.Fatalf("RegenerateEinladungToken: %v", err)
}
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("expected the old token to be invalid, got err = %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
if err != nil {
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
}
} }
func TestUserCRUD(t *testing.T) { func TestUserCRUD(t *testing.T) {
@@ -141,6 +224,9 @@ func TestSessionCRUD(t *testing.T) {
if got.UserID != user.ID { if got.UserID != user.ID {
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID) t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
} }
if got.ImpersonatedByUserID != nil {
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
}
if err := s.DeleteSession(ctx, sess.Token); err != nil { if err := s.DeleteSession(ctx, sess.Token); err != nil {
t.Fatalf("DeleteSession: %v", err) t.Fatalf("DeleteSession: %v", err)
@@ -157,3 +243,38 @@ func TestGetSessionNotFound(t *testing.T) {
t.Fatalf("err = %v, want store.ErrNotFound", err) t.Fatalf("err = %v, want store.ErrNotFound", err)
} }
} }
func TestCreateImpersonatedSession(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
kundenAcc := testAccountID(t, s)
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
if err != nil {
t.Fatalf("CreateUser (Kunde): %v", err)
}
betreiberAcc := testAccountID(t, s)
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser (Betreiber): %v", err)
}
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
if err != nil {
t.Fatalf("CreateImpersonatedSession: %v", err)
}
if sess.UserID != kundenNutzer.ID {
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
}
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
}
got, err := s.GetSession(ctx, sess.Token)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
}
}

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

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

388
internal/store/freigabe.go Normal file
View File

@@ -0,0 +1,388 @@
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
// sein oder keines von beidem.
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type GenehmigerRolle struct {
ID string
AccountID string
Name string
Beschreibung string
CreatedAt time.Time
}
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
// Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
// Bedingung, das bleibt Sache von freigabe_regel.
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
var g GenehmigerRolle
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
RETURNING id, account_id, name, beschreibung, created_at
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
if err != nil {
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
}
return g, nil
}
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
var g GenehmigerRolle
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return GenehmigerRolle{}, ErrNotFound
}
if err != nil {
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
}
return g, nil
}
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
// Mandanten.
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
WHERE account_id = $1 ORDER BY name
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
}
defer rows.Close()
var out []GenehmigerRolle
for rows.Next() {
var g GenehmigerRolle
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
_, err := s.db(ctx).Exec(ctx, `
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
`, userID, genehmigerRolleID)
if err != nil {
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
}
return nil
}
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
_, err := s.db(ctx).Exec(ctx, `
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
`, userID, genehmigerRolleID)
if err != nil {
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
}
return nil
}
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT gr.id, gr.account_id, gr.name, gr.created_at
FROM genehmiger_rolle gr
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
WHERE ngr.app_user_id = $1
ORDER BY gr.name
`, userID)
if err != nil {
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
}
defer rows.Close()
var out []GenehmigerRolle
for rows.Next() {
var g GenehmigerRolle
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
type GenehmigerMitglied struct {
UserID string
Email string
}
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
// bestimmten Genehmiger-Rolle.
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT u.id, u.email
FROM app_user u
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
WHERE ngr.genehmiger_rolle_id = $1
ORDER BY u.email
`, genehmigerRolleID)
if err != nil {
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
}
defer rows.Close()
var out []GenehmigerMitglied
for rows.Next() {
var m GenehmigerMitglied
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
type FreigabeRegel struct {
ID string
AccountID string
BedingungTyp string
BedingungWert string
GenehmigerRolleID string
CreatedAt time.Time
}
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
var f FreigabeRegel
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
VALUES ($1, $2, $3, $4)
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
)
if err != nil {
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
}
return f, nil
}
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
}
defer rows.Close()
var out []FreigabeRegel
for rows.Next() {
var f FreigabeRegel
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete freigabe regel: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
var f FreigabeRegel
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
FROM freigabe_regel WHERE id = $1
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return FreigabeRegel{}, ErrNotFound
}
if err != nil {
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
}
return f, nil
}
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
// append-only — ein Schritt geht von "ausstehend" in genau einen
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
// Schritt.
type Freigabeschritt struct {
ID string
AntragID string
GenehmigerRolleID string
Status string // ausstehend/genehmigt/abgelehnt
EntschiedenVon *string
EntschiedenAm *time.Time
Kommentar string
CreatedAt time.Time
}
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
var f Freigabeschritt
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
`, antragID, genehmigerRolleID).Scan(
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
)
if err != nil {
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
}
return f, nil
}
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
var f Freigabeschritt
err := s.db(ctx).QueryRow(ctx, `
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
FROM freigabeschritt WHERE id = $1
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Freigabeschritt{}, ErrNotFound
}
if err != nil {
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
}
return f, nil
}
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
`, antragID)
if err != nil {
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
}
defer rows.Close()
var out []Freigabeschritt
for rows.Next() {
var f Freigabeschritt
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
// innehat — Grundlage für "Meine Freigaben".
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
FROM freigabeschritt f
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
ORDER BY f.created_at
`, userID)
if err != nil {
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
}
defer rows.Close()
var out []Freigabeschritt
for rows.Next() {
var f Freigabeschritt
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
// entschiedenen Schritten wäre irreführend, deshalb hier ein
// generischer Fehler statt eines Sentinels).
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
var f Freigabeschritt
err := s.db(ctx).QueryRow(ctx, `
UPDATE freigabeschritt
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
WHERE id = $1 AND status = 'ausstehend'
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
`, id, status, entschiedenVon, kommentar).Scan(
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Freigabeschritt{}, ErrNotFound
}
if err != nil {
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
}
return f, nil
}
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
// hängen zu bleiben.
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
_, err := s.db(ctx).Exec(ctx, `
UPDATE freigabeschritt
SET status = 'abgelehnt', entschieden_am = now(),
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
`, antragID, ausloesenderSchrittID)
if err != nil {
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
}
return nil
}

View File

@@ -0,0 +1,174 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err)
}
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
}
got, err := s.GetGenehmigerRolle(ctx, g.ID)
if err != nil || got.Name != "Datenschutzbeauftragter" {
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
}
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
if err != nil || len(liste) != 1 {
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
}
user := testUserID(t, s, accID)
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
}
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
// kein Fehler.
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
}
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
}
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
}
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
}
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
if err != nil || len(rollenNachEntfernen) != 0 {
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
}
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
t.Fatalf("DeleteGenehmigerRolle: %v", err)
}
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
}
}
func TestFreigabeRegelCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err)
}
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
if err != nil {
t.Fatalf("CreateFreigabeRegel: %v", err)
}
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
}
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
if err != nil || len(liste) != 1 {
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
}
got, err := s.GetFreigabeRegel(ctx, regel.ID)
if err != nil || got.ID != regel.ID {
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
}
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
t.Fatalf("DeleteFreigabeRegel: %v", err)
}
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
}
}
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
antragID := antrag.ID
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
if err != nil {
t.Fatalf("CreateFreigabeschritt: %v", err)
}
if schrittDSB.Status != "ausstehend" {
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
}
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
if err != nil {
t.Fatalf("CreateFreigabeschritt: %v", err)
}
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
if err != nil || len(alle) != 2 {
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
}
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
if err != nil {
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
}
if len(ausstehend) != 0 {
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
}
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
}
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
}
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
if err != nil {
t.Fatalf("EntscheideFreigabeschritt: %v", err)
}
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
}
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
}
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
t.Fatalf("KaskadiereAblehnung: %v", err)
}
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
if err != nil {
t.Fatalf("GetFreigabeschritt: %v", err)
}
if nachKaskade.Status != "abgelehnt" {
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
}
}

View File

@@ -0,0 +1,69 @@
// Löschfristen je Datenklasse (Migration 0020) — schließt die in
// rules/OPEN.md, Punkt 4 dokumentierte Lücke: loeschfrist_max_tage war
// bisher nur als Anforderung "vorhanden", ohne Tageswerte, und wurde
// deshalb nicht hart gefiltert. Die konkreten Tageswerte sind KEINE
// gesetzliche Vorgabe (die DSGVO nennt keine festen Fristen, nur den
// Grundsatz "so lange wie für den Zweck nötig", Art. 5 Abs. 1 lit. e) —
// deshalb pro Mandant einstellbar statt hartkodiert, vom
// Datenschutzbeauftragten hinterlegt. Bei Firmenanlage werden
// risikogestaffelte Vorschlagswerte vorbelegt (siehe
// internal/web/loeschfrist_handlers.go), frei editierbar.
package store
import (
"context"
"fmt"
"time"
)
type LoeschfristEinstellung struct {
ID string
AccountID string
DatenklasseID string
MaxTage int
UpdatedAt time.Time
}
// UpsertLoeschfristEinstellung legt eine Einstellung an oder
// aktualisiert sie — ein Mandant hat höchstens eine Frist je
// Datenklasse (UNIQUE-Constraint).
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
var e LoeschfristEinstellung
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
VALUES ($1, $2, $3)
ON CONFLICT (account_id, datenklasse_id)
DO UPDATE SET max_tage = $3, updated_at = now()
RETURNING id, account_id, datenklasse_id, max_tage, updated_at
`, accountID, datenklasseID, maxTage).Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt)
if err != nil {
return LoeschfristEinstellung{}, fmt.Errorf("store: upsert loeschfrist einstellung: %w", err)
}
return e, nil
}
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
// Fristen eines Mandanten.
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, datenklasse_id, max_tage, updated_at
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
}
defer rows.Close()
var out []LoeschfristEinstellung
for rows.Next() {
var e LoeschfristEinstellung
if err := rows.Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt); err != nil {
return nil, fmt.Errorf("store: scan loeschfrist einstellung: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,41 @@
package store_test
import (
"context"
"testing"
)
func TestLoeschfristEinstellungUpsert(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
e, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 90)
if err != nil {
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
}
if e.MaxTage != 90 {
t.Fatalf("MaxTage = %d, want 90", e.MaxTage)
}
// Erneutes Upsert für dieselbe Datenklasse überschreibt statt zu duplizieren.
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 30); err != nil {
t.Fatalf("UpsertLoeschfristEinstellung (Update): %v", err)
}
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "besondere_kategorie", 14); err != nil {
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
}
liste, err := s.ListLoeschfristEinstellungenForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListLoeschfristEinstellungenForAccount: %v", err)
}
if len(liste) != 2 {
t.Fatalf("got %d Einstellungen, want 2 (Upsert darf nicht duplizieren)", len(liste))
}
for _, e := range liste {
if e.DatenklasseID == "personenbezogen" && e.MaxTage != 30 {
t.Fatalf("personenbezogen MaxTage = %d, want 30 nach Update", e.MaxTage)
}
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE account DROP COLUMN einladung_token;

View File

@@ -0,0 +1,8 @@
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
-- dass store.CreateAccount etwas ändern muss.
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
-- bestmögliche Näherung statt Datenverlust durch NULL.
UPDATE werkzeug SET verarbeitungsort =
CASE
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
ELSE 'EU'
END;
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;

View File

@@ -0,0 +1,29 @@
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
-- OpenAI/Microsoft).
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
UPDATE werkzeug SET verarbeitungslaender =
CASE verarbeitungsort
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
WHEN 'USA' THEN ARRAY['USA']
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
END;
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
-- filterbar.
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,9 @@
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
DROP TABLE freigabeschritt;
DROP TABLE freigabe_regel;
DROP TABLE nutzer_genehmiger_rolle;
DROP TABLE genehmiger_rolle;

View File

@@ -0,0 +1,59 @@
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
-- und eine Person kann mehrere davon gleichzeitig innehaben.
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
CREATE TABLE genehmiger_rolle (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, name)
);
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
CREATE TABLE nutzer_genehmiger_rolle (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_user_id UUID NOT NULL REFERENCES app_user (id),
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (app_user_id, genehmiger_rolle_id)
);
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
CREATE TABLE freigabe_regel (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
bedingung_wert TEXT NOT NULL,
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
CREATE TABLE freigabeschritt (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
antrag_id UUID NOT NULL REFERENCES antrag (id),
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
entschieden_von UUID REFERENCES app_user (id),
entschieden_am TIMESTAMPTZ,
kommentar TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
CREATE TABLE password_reset_token (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES app_user(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX password_reset_token_user_id_idx ON password_reset_token(user_id);

View File

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

View File

@@ -0,0 +1,8 @@
CREATE TABLE loeschfrist_einstellung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account(id),
datenklasse_id TEXT NOT NULL,
max_tage INTEGER NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, datenklasse_id)
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ func scanUser(row interface {
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store // passwordHash muss bereits gehasht sein (siehe internal/auth) — store
// speichert nur, es hasht nicht selbst. // speichert nur, es hasht nicht selbst.
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) { 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) INSERT INTO app_user (account_id, email, password_hash, role)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING `+userColumns, 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. // GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler). // Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { 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) u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound 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. // GetUser liest einen Nutzer anhand seiner ID.
func (s *Store) GetUser(ctx context.Context, id string) (User, error) { 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) u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound 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 // ListUsersForAccount liefert alle Logins eines Mandanten — für den
// Admin-Bereich (Account-Detailansicht). // Admin-Bereich (Account-Detailansicht).
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) { 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 SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
`, accountID) `, accountID)
if err != nil { if err != nil {
@@ -108,12 +108,26 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
return out, nil 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 // SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber // kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen // als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
// nachvollziehbar — deshalb (de-)aktivieren statt löschen. // nachvollziehbar — deshalb (de-)aktivieren statt löschen.
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error { 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 { if err != nil {
return fmt.Errorf("store: set user active: %w", err) return fmt.Errorf("store: set user active: %w", err)
} }

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
// und kann entscheiden, genau wie ein verantwortlicher.
func TestAdminSiehtPosteingang(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
}
}
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
}
}
func TestAdminKannEntscheiden(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "entschieden" {
t.Errorf("Status = %q, want entschieden", antrag.Status)
}
}
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
resp := getWithCookie(t, s, adminCookie, path)
if resp.Code != http.StatusOK {
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
}
}
}

View File

@@ -8,8 +8,10 @@ package web
import ( import (
"net/http" "net/http"
"strings"
"github.com/netcell-it/deklarix/internal/auth" "github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
) )
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen // mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
@@ -35,9 +37,11 @@ type nutzerListItem struct {
} }
type nutzerListeData struct { type nutzerListeData struct {
Title string Title string
Nav navData Nav navData
Nutzer []nutzerListItem Nutzer []nutzerListItem
Suche string
Pagination pagination
} }
// handleNutzerListe zeigt alle Logins des eigenen Mandanten. // 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) http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)} q := strings.TrimSpace(r.URL.Query().Get("q"))
var gefiltert []store.User
for _, u := range users { 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{ data.Nutzer = append(data.Nutzer, nutzerListItem{
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID, 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" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
@@ -27,6 +28,115 @@ var datenfragen = []struct {
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"}, {"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
} }
var c2FolgeLabels = map[string]string{
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
"kreditwuerdigkeit": "Kreditwürdigkeit",
"leistungsbewilligung": "Leistungsbewilligung",
"bildung": "Zugang zu Bildung",
"gesundheit": "medizinische Beurteilung",
}
var c3ArtLabels = map[string]string{
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
"social_scoring": "Social Scoring",
"biometrische_kategorisierung": "Biometrische Kategorisierung",
}
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
// siehe antwortenAnzeige.
type antwortZeile struct {
Frage string
Antwort string
}
func antwortLabel(v string) string {
switch v {
case "ja":
return "Ja"
case "nein":
return "Nein"
case "unsicher":
return "Unsicher"
default:
return v
}
}
func jaNein(v bool) string {
if v {
return "Ja"
}
return "Nein"
}
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
// Antworten, auf denen sie beruht — im Audit ist das nicht
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
var out []antwortZeile
for _, f := range datenfragen {
v, _ := antworten[f.Key].(string)
if v == "" {
v = "nein"
}
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
}
c1, _ := antworten["c1"].(bool)
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
c2, _ := antworten["c2"].(bool)
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
if c2 {
if folge, _ := antworten["c2_folge"].(string); folge != "" {
if label, ok := c2FolgeLabels[folge]; ok {
c2Zeile.Antwort += " — " + label
}
}
}
out = append(out, c2Zeile)
c3, _ := antworten["c3"].(bool)
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
if c3 {
if art, _ := antworten["c3_art"].(string); art != "" {
if label, ok := c3ArtLabels[art]; ok {
c3Zeile.Antwort += " — " + label
}
}
}
out = append(out, c3Zeile)
c4, _ := antworten["c4"].(bool)
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
c5, _ := antworten["c5"].(bool)
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
}
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
label := "Privater Zugang"
if zugang == "firma" {
label = "Firmenkonto"
}
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
}
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
label := "Privatgerät"
if geraet == "firma" {
label = "Firmengerät"
}
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
}
return out
}
type antragFormData struct { type antragFormData struct {
Title string Title string
Nav navData Nav navData
@@ -181,11 +291,24 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge)) kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
for i, w := range werkzeuge { for i, w := range werkzeuge {
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{ kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort, AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage, TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: 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 in.ZulaessigeWerkzeuge = zulaessig
for _, a := range ausgeschlossen { for _, a := range ausgeschlossen {
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt}) in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
@@ -221,9 +344,11 @@ type antragListItem struct {
} }
type antragListData struct { type antragListData struct {
Title string Title string
Nav navData Nav navData
Antraege []antragListItem Antraege []antragListItem
Suche string
Pagination pagination
} }
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene // handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
@@ -238,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) http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return 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 { 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{ data.Antraege = append(data.Antraege, antragListItem{
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit, ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
@@ -251,7 +384,6 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
} }
type anforderungView struct { type anforderungView struct {
ID string
Beschreibung string Beschreibung string
Herleitung string Herleitung string
} }
@@ -278,14 +410,13 @@ type bewertungView struct {
type antragDetailData struct { type antragDetailData struct {
Title string Title string
Nav navData Nav navData
ID string
Titel string Titel string
Beschreibung string Beschreibung string
Ergebnis string Ergebnis string
Haeufigkeit string Haeufigkeit string
Status string Status string
CreatedAt string CreatedAt string
Antworten map[string]any Antworten []antwortZeile
Bewertung *bewertungView Bewertung *bewertungView
} }
@@ -306,9 +437,9 @@ func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
} }
data := antragDetailData{ data := antragDetailData{
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel, Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit, Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten, Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
} }
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID) bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
@@ -333,7 +464,7 @@ func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewert
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"), CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
} }
for _, a := range b.Anforderungen { for _, a := range b.Anforderungen {
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung}) v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
} }
for _, id := range b.ZulaessigeWerkzeuge { for _, id := range b.ZulaessigeWerkzeuge {
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id)) v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))

View File

@@ -75,8 +75,15 @@ func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
if detailResp.Code != http.StatusOK { if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String()) t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
} }
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") { body := detailResp.Body.String()
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String()) if !strings.Contains(body, "Angebotstexte generieren") {
t.Errorf("expected the antrag title on the detail page, got: %s", body)
}
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
}
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
} }
} }
@@ -241,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) { func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
fs := newFakeStore() fs := newFakeStore()
s := newServer(t, fs) s := newServer(t, fs)

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"context"
"net/http" "net/http"
"time" "time"
@@ -8,8 +9,9 @@ import (
) )
type authPageData struct { type authPageData struct {
Title string Title string
Error string Error string
Message string
} }
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) { func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
@@ -23,7 +25,11 @@ func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { 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- // handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
@@ -31,8 +37,9 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb // Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows). // wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne // Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink // Mitarbeiter über dieses Formular — die kommen über den
// (spätere Ausbaustufe), nicht über dieses Formular. // Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
// Import/Mandantenlink (weiterhin nicht gebaut).
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"}) s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
@@ -53,20 +60,52 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
ctx := r.Context() // Die account_id ist zu Beginn noch nicht bekannt (die Firma entsteht
acc, err := s.store.CreateAccount(ctx, accountName) // ja gerade erst) — WithTenantScope startet die RLS-Transaktion
// zunächst ohne Mandant, SetTenantScope trägt die neue ID nach,
// sobald CreateAccount sie liefert. Ohne das würden die anschließenden
// Schreibzugriffe auf genehmiger_rolle/loeschfrist_einstellung (beide
// RLS-geschützt) von der Policy abgelehnt.
var errMsg string
err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error {
acc, err := s.store.CreateAccount(ctx, accountName)
if err != nil {
errMsg = "Konto konnte nicht angelegt werden"
return err
}
if err := s.store.SetTenantScope(ctx, acc.ID, false); err != nil {
errMsg = "Interner Fehler"
return err
}
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
if err != nil {
errMsg = "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"
return err
}
if err := s.seedStandardGenehmigerRollen(ctx, acc.ID); err != nil {
errMsg = "Genehmiger-Rollen konnten nicht angelegt werden"
return err
}
if err := s.seedStandardLoeschfristen(ctx, acc.ID); err != nil {
errMsg = "Löschfristen konnten nicht angelegt werden"
return err
}
// r.WithContext(ctx): startSession muss in DERSELBEN Transaktion
// laufen wie CreateUser darüber — sonst sieht die Session-Zeile
// (andere Verbindung über s.Pool, da r.Context() sonst ohne Tx
// wäre) den gerade erst angelegten, noch nicht committeten
// Nutzer nicht und die Fremdschlüsselprüfung schlägt fehl.
if err := s.startSession(w, r.WithContext(ctx), user.ID); err != nil {
errMsg = "Sitzung konnte nicht gestartet werden"
return err
}
return nil
})
if err != nil { if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"}) if errMsg == "" {
return errMsg = "Registrierung fehlgeschlagen"
} }
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin") s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: errMsg})
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"})
return return
} }
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -139,6 +178,14 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil { if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
return err return err
} }
setSessionCookie(w, r, token, expiresAt)
return nil
}
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
// startSession (regulärer Login) und handleBetreiberLoginAls
// (Support-Login).
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Name: sessionCookieName,
Value: token, Value: token,
@@ -148,5 +195,4 @@ func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID str
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
Expires: expiresAt, Expires: expiresAt,
}) })
return nil
} }

View File

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

View File

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

View File

@@ -14,11 +14,79 @@ import (
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
) )
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"} // werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
type werkzeugFilter struct {
Suche string
Zweck string
}
func istGueltigerVerarbeitungsort(v string) bool { func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
for _, o := range gueltigeVerarbeitungsorte { return werkzeugFilter{
if o == v { Suche: strings.TrimSpace(r.URL.Query().Get("q")),
Zweck: r.URL.Query().Get("zweck"),
}
}
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
// nur exportierte Methoden per Reflection aufrufen kann.
func (f werkzeugFilter) Aktiv() bool {
return f.Suche != "" || f.Zweck != ""
}
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
if f.Zweck != "" {
gefunden := false
for _, z := range wz.GeeigneteZwecke {
if z == f.Zweck {
gefunden = true
break
}
}
if !gefunden {
return false
}
}
if f.Suche != "" {
q := strings.ToLower(f.Suche)
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
if !strings.Contains(heuhaufen, q) {
return false
}
}
return true
}
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
// nur für Verarbeitungsländer statt Zwecke.
func istGueltigesLand(v string) bool {
for _, l := range weltLaenderliste {
if l == v {
return true
}
}
return false
}
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
// (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
// Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
// künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
// 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
// v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
// — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
var gueltigeZwecke = []string{
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
}
func istGueltigerZweck(v string) bool {
for _, z := range gueltigeZwecke {
if z == v {
return true return true
} }
} }
@@ -40,17 +108,69 @@ func splitListe(s string) []string {
} }
type werkzeugListItem struct { type werkzeugListItem struct {
ID string ID string
Name string Name string
Anbieter string Anbieter string
Verarbeitungsort string Verarbeitungslaender string
LetztePruefung string DPFZertifiziert bool
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
Zertifizierungen string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS string
}
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
// sofortigen Löschung) verwechselt wird.
func formatAufbewahrung(tage *int) string {
if tage == nil {
return "unbekannt"
}
if *tage == 0 {
return "0 Tage (sofort)"
}
return strconv.Itoa(*tage) + " Tage"
} }
type werkzeugListeData struct { type werkzeugListeData struct {
Title string Title string
Nav navData Nav navData
Werkzeuge []werkzeugListItem Werkzeuge []werkzeugListItem
Filter werkzeugFilter
ZweckeOptionen []string
Pagination pagination
}
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
// nicht erst wenn die Frist schon gerissen ist.
const (
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
)
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
func pruefStatus(letztePruefung time.Time) (label, css string) {
alter := time.Since(letztePruefung)
switch {
case alter > werkzeugPruefungVeraltetNach:
return "abgelaufen", "status-hoch"
case alter > werkzeugPruefungBaldFaelligNach:
return "bald fällig", "status-mittel"
default:
return "aktuell", "status-niedrig"
}
} }
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog. // handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
@@ -60,11 +180,30 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)} filter := werkzeugFilterFromRequest(r)
var gefiltert []store.Werkzeug
for _, wz := range werkzeuge { for _, wz := range werkzeuge {
if filter.matches(wz) {
gefiltert = append(gefiltert, wz)
}
}
pag := paginationFromRequest(r, len(gefiltert))
seite := paginate(gefiltert, pag)
data := werkzeugListeData{
Title: "Werkzeugkatalog", Nav: navFor(r),
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
}
for _, wz := range seite {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{ data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), DPFZertifiziert: wz.DPFZertifiziert,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
}) })
} }
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
@@ -72,45 +211,93 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
} }
} }
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
type werkzeugFormData struct { type werkzeugFormData struct {
Title string Title string
Nav navData Nav navData
ID string // leer bei "neu" ActionBase string
Name string ID string // leer bei "neu"
Anbieter string Name string
Verarbeitungsorte []string Anbieter string
Verarbeitungsort string LaenderOptionen []string
AVVVerfuegbar bool LaenderAusgewaehlt map[string]bool
AVVURL string DPFZertifiziert bool
TrainingOptOut bool AVVVerfuegbar bool
TrainingStandard bool AVVURL string
AufbewahrungTage int TrainingOptOut bool
Zertifizierungen string TrainingStandard bool
GeeigneteZwecke string AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
Einschraenkungen string Zertifizierungen string
LetztePruefung string Subprozessoren string
Quelle string ZweckeOptionen []string
Error string ZweckeAusgewaehlt map[string]bool
Einschraenkungen string
LetztePruefung string
Quelle string
Error string
}
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
// gespeicherten oder erneut eingereichten Liste von Zwecken.
func auswahlSet(ausgewaehlt []string) map[string]bool {
m := make(map[string]bool, len(ausgewaehlt))
for _, z := range ausgewaehlt {
m[z] = true
}
return m
}
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
aufbewahrung := ""
if wz.AufbewahrungTage != nil {
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
}
return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
Name: wz.Name, Anbieter: wz.Anbieter,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
} }
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), Verarbeitungsorte: gueltigeVerarbeitungsorte} data := werkzeugFormData{
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
} }
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular. // werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
// AccountID ist immer nil — diese Seiten pflegen ausschließlich den // accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
// zentralen Katalog. // für einen mandantenspezifischen Eintrag (Ebene 4).
func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) { func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung")) letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
if err != nil { if err != nil {
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt" return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
} }
verarbeitungsort := r.FormValue("verarbeitungsort") var verarbeitungslaender []string
if !istGueltigerVerarbeitungsort(verarbeitungsort) { for _, l := range r.Form["verarbeitungslaender"] {
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort" if istGueltigesLand(l) {
verarbeitungslaender = append(verarbeitungslaender, l)
}
}
if len(verarbeitungslaender) == 0 {
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
} }
name := r.FormValue("name") name := r.FormValue("name")
anbieter := r.FormValue("anbieter") anbieter := r.FormValue("anbieter")
@@ -118,36 +305,56 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
if name == "" || anbieter == "" || quelle == "" { if name == "" || anbieter == "" || quelle == "" {
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht" return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
} }
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage")) var aufbewahrungTage *int
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
wert, err := strconv.Atoi(rohwert)
if err != nil || wert < 0 {
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
}
aufbewahrungTage = &wert
}
var geeigneteZwecke []string
for _, z := range r.Form["geeignete_zwecke"] {
if istGueltigerZweck(z) {
geeigneteZwecke = append(geeigneteZwecke, z)
}
}
return store.WerkzeugInput{ return store.WerkzeugInput{
AccountID: nil, AccountID: accountID,
Name: name, Name: name,
Anbieter: anbieter, Anbieter: anbieter,
Verarbeitungsort: verarbeitungsort, Verarbeitungslaender: verarbeitungslaender,
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
AVVURL: r.FormValue("avv_url"), AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingOptOut: r.FormValue("training_opt_out") == "on",
TrainingStandard: r.FormValue("training_standard") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: aufbewahrungTage, DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")), AufbewahrungTage: aufbewahrungTage,
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")), Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")), Subprozessoren: splitListe(r.FormValue("subprozessoren")),
LetztePruefung: letztePruefung, GeeigneteZwecke: geeigneteZwecke,
Quelle: quelle, Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
LetztePruefung: letztePruefung,
Quelle: quelle,
}, "" }, ""
} }
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei // formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten, // einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
// statt das Formular zu leeren. // statt das Formular zu leeren.
func werkzeugFormDataFromRequest(r *http.Request, title, id, errMsg string) werkzeugFormData { func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
return werkzeugFormData{ return werkzeugFormData{
Title: title, Nav: navFor(r), ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte, Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"), LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"), AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on", TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"), AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"), Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
Quelle: r.FormValue("quelle"), Error: errMsg, Quelle: r.FormValue("quelle"), Error: errMsg,
} }
@@ -159,16 +366,16 @@ func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Re
http.Error(w, "ungültiges Formular", http.StatusBadRequest) http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return return
} }
in, errMsg := werkzeugInputFromForm(r) in, errMsg := werkzeugInputFromForm(r, nil)
if errMsg != "" { if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", errMsg) data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
return return
} }
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil { if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", "Werkzeug konnte nicht angelegt werden: "+err.Error()) data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
@@ -187,15 +394,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return return
} }
data := werkzeugFormData{ data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
Title: "Werkzeug bearbeiten", Nav: navFor(r), ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
@@ -216,16 +415,16 @@ func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Re
http.Error(w, "ungültiges Formular", http.StatusBadRequest) http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return return
} }
in, errMsg := werkzeugInputFromForm(r) in, errMsg := werkzeugInputFromForm(r, nil)
if errMsg != "" { if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, errMsg) data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
return return
} }
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil { if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error()) data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }

View File

@@ -2,17 +2,19 @@ package web_test
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"time"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
) )
func werkzeugForm() url.Values { func werkzeugForm() url.Values {
return url.Values{ return url.Values{
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"}, "name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"}, "avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"}, "training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"}, "zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
@@ -116,3 +118,71 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code) t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
} }
} }
func TestBetreiberWerkzeugListeFiltertNachSucheUndZweck(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
fs.werkzeuge["a"] = store.Werkzeug{
ID: "a", Name: "ChatGPT Enterprise", Anbieter: "OpenAI",
Verarbeitungslaender: []string{"USA"}, GeeigneteZwecke: []string{"Textgenerierung"},
LetztePruefung: time.Now(),
}
fs.werkzeuge["b"] = store.Werkzeug{
ID: "b", Name: "DeepL Pro", Anbieter: "DeepL SE",
Verarbeitungslaender: []string{"Deutschland"}, GeeigneteZwecke: []string{"Übersetzung"},
LetztePruefung: time.Now(),
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=deepl")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d", resp.Code)
}
if strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || !strings.Contains(resp.Body.String(), "DeepL Pro") {
t.Fatalf("q=deepl sollte nur DeepL Pro zeigen, body: %s", resp.Body.String())
}
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?zweck=Textgenerierung")
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || strings.Contains(resp.Body.String(), "DeepL Pro") {
t.Fatalf("zweck=Textgenerierung sollte nur ChatGPT Enterprise zeigen, body: %s", resp.Body.String())
}
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=nichts-passt")
if !strings.Contains(resp.Body.String(), "Keine Treffer") {
t.Fatalf("erwartet 'Keine Treffer' bei erfolgloser Suche, body: %s", resp.Body.String())
}
}
func TestBetreiberWerkzeugListePaginiert(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
for i := 0; i < 25; i++ {
id := fmt.Sprintf("wz-%02d", i)
fs.werkzeuge[id] = store.Werkzeug{
ID: id, Name: fmt.Sprintf("Werkzeug %02d", i), Anbieter: "Testanbieter",
LetztePruefung: time.Now(),
}
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Seite 1 von 2") {
t.Fatalf("erwartet 'Seite 1 von 2' bei 25 Einträgen (20 pro Seite), body: %s", resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Weiter") || strings.Contains(resp.Body.String(), "Zurück") {
t.Fatalf("Seite 1 sollte einen Weiter-, aber keinen Zurück-Button zeigen")
}
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?page=2")
if !strings.Contains(resp.Body.String(), "Seite 2 von 2") {
t.Fatalf("erwartet 'Seite 2 von 2', body: %s", resp.Body.String())
}
if strings.Contains(resp.Body.String(), "Weiter") || !strings.Contains(resp.Body.String(), "Zurück") {
t.Fatalf("Seite 2 sollte einen Zurück-, aber keinen Weiter-Button zeigen")
}
}

View File

@@ -0,0 +1,184 @@
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
// beim einzelnen "Nutzer anlegen" generiert der Import ein
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
package web
import (
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/auth"
)
type csvMitarbeiterZeile struct {
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
Email string
Role string
}
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
// einzelne falsche Rolle nicht den ganzen Import verhindert).
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
}
emailCol, roleCol := -1, -1
for i, h := range header {
switch strings.ToLower(strings.TrimSpace(h)) {
case "email", "e-mail":
emailCol = i
case "role", "rolle":
roleCol = i
}
}
if emailCol == -1 {
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
}
var out []csvMitarbeiterZeile
zeile := 1
for {
record, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
}
zeile++
if emailCol >= len(record) {
continue
}
email := strings.TrimSpace(record[emailCol])
if email == "" {
continue
}
role := "mitarbeiter"
if roleCol != -1 && roleCol < len(record) {
if v := strings.TrimSpace(record[roleCol]); v != "" {
role = v
}
}
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
}
return out, nil
}
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
// Länge, die sich noch manuell weitergeben lässt.
func generateEinmalPasswort() (string, error) {
token, err := auth.NewSessionToken()
if err != nil {
return "", err
}
return token[:16], nil
}
type csvImportFormData struct {
Title string
Nav navData
Error string
}
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type csvImportErgebnisZeile struct {
Zeile int
Email string
Role string
Passwort string // nur bei Erfolg gesetzt
Fehler string // nur bei Fehler gesetzt
}
type csvImportErgebnisData struct {
Title string
Nav navData
Ergebnisse []csvImportErgebnisZeile
}
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
return
}
file, _, err := r.FormFile("csv")
if err != nil {
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
return
}
defer file.Close()
zeilen, err := parseCSVMitarbeiter(file)
if err != nil {
s.renderCSVImportForm(w, r, err.Error())
return
}
if len(zeilen) == 0 {
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
return
}
accountID := currentUser(r).AccountID
var ergebnisse []csvImportErgebnisZeile
for _, z := range zeilen {
if !istMandantenRolle(z.Role) {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
continue
}
passwort, err := generateEinmalPasswort()
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
continue
}
hash, err := auth.HashPassword(passwort)
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
continue
}
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
continue
}
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
}
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,149 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/web"
)
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
// — reale Browser-Uploads laufen genauso über multipart/form-data.
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := fw.Write([]byte(csvContent)); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
t.Fatalf("expected both imported emails in the result, got: %s", body)
}
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (lisa): %v", err)
}
if lisa.Role != "verantwortlicher" {
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
}
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (tom): %v", err)
}
if tom.Role != "mitarbeiter" {
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
}
}
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
body := resp.Body.String()
start := strings.Index(body, "<code>")
end := strings.Index(body, "</code>")
if start == -1 || end == -1 {
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
}
passwort := body[start+len("<code>") : end]
loginResp := postForm(t, s, nil, "/login", map[string][]string{
"email": {"neu@example.com"}, "password": {passwort},
})
if loginResp.Code != http.StatusSeeOther {
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
}
}
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
t.Error("expected no user to be created for an invalid role (betreiber)")
}
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
t.Error("expected the valid row to still be imported despite the earlier invalid row")
}
}
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(adminCookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
}
}
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -0,0 +1,64 @@
package web
import (
"strings"
"testing"
)
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 {
t.Fatalf("zeilen = %+v, want 2", zeilen)
}
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
}
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
}
}
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
csv := "email\nlisa@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
}
}
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
}
}
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
csv := "name,role\nLisa,mitarbeiter\n"
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err == nil {
t.Fatal("expected an error when the header has no email column")
}
}
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
csv := "email\nlisa@example.com\ntom@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
}
}

View File

@@ -0,0 +1,140 @@
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
// Abschnitt A), nicht hier.
package web
import (
"net/http"
"github.com/netcell-it/deklarix/internal/auth"
)
type einladungFormData struct {
Title string
Firmenname string
Error string
}
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
// existiert hat oder nur nicht mehr gültig ist.
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
s.renderEinladungForm(w, acc.Name, "")
}
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderEinladungError(w http.ResponseWriter) {
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
if err := r.ParseForm(); err != nil {
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
return
}
email := r.FormValue("email")
password := r.FormValue("password")
if email == "" || password == "" {
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
s.renderEinladungForm(w, acc.Name, err.Error())
return
}
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
if err != nil {
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
type einladungVerwaltungData struct {
Title string
Nav navData
Link string
}
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
// X-Forwarded-Proto berücksichtigen.
func einladungBasisURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
// Mandanten zum Kopieren.
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := einladungVerwaltungData{
Title: "Einladungslink", Nav: navFor(r),
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
}
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
// außerhalb des Unternehmens geteilt wurde).
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
newToken, err := auth.NewSessionToken()
if err != nil {
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
}

View File

@@ -0,0 +1,144 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestEinladungFormZeigtFirmenname(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
}
}
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
}
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if len(resp.Result().Cookies()) == 0 {
t.Fatal("expected a session cookie to be set")
}
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
}
cookie := resp.Result().Cookies()[0]
protected := getWithCookie(t, s, cookie, "/antraege")
if protected.Code != http.StatusOK {
t.Fatalf("expected the new session to work, status = %d", protected.Code)
}
}
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
if len(fs.users) != 0 {
t.Error("expected no user to be created for an invalid token")
}
}
func TestAdminSiehtEinladungslink(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "/einladung/") {
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
}
}
func TestAdminKannEinladungslinkErneuern(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
altesToken := fs.accounts[admin.AccountID].EinladungToken
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
neuesToken := fs.accounts[admin.AccountID].EinladungToken
if neuesToken == altesToken {
t.Fatal("expected the token to change")
}
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
}
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
}
}
func TestMitarbeiterCannotAccessEinladungsverwaltung(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/einladung")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -5,7 +5,10 @@
package web package web
import ( import (
"context"
"encoding/json"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
@@ -19,9 +22,11 @@ type fallListItem struct {
} }
type fallListData struct { type fallListData struct {
Title string Title string
Nav navData Nav navData
Faelle []fallListItem Faelle []fallListItem
Suche string
Pagination pagination
} }
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status // handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
@@ -33,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) http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := fallListData{Title: "Posteingang", Nav: navFor(r)} q := strings.TrimSpace(r.URL.Query().Get("q"))
var offene []fallListItem
for _, a := range antraege { for _, a := range antraege {
if a.Status != "eingereicht" { if a.Status != "eingereicht" {
continue 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, ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), 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 { if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
@@ -71,13 +85,30 @@ type fallDetailData struct {
Haeufigkeit string Haeufigkeit string
Status string Status string
CreatedAt string CreatedAt string
Antworten []antwortZeile
Bewertung *bewertungView Bewertung *bewertungView
Entscheidung *entscheidungView Entscheidung *entscheidungView
CanEntscheiden bool CanEntscheiden bool
WerkzeugOptionen []werkzeugOption WerkzeugOptionen []werkzeugOption
Freigabeschritte []freigabeschrittView
Error string 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.
func hatEntscheidungsrecht(role string) bool {
return role == "verantwortlicher" || role == "admin"
}
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das // vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob // System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
// eine tatsächliche Entscheidung davon abweicht (dann ist die // eine tatsächliche Entscheidung davon abweicht (dann ist die
@@ -102,10 +133,15 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
return return
} }
var antworten map[string]any
if len(antrag.Antworten) > 0 {
_ = json.Unmarshal(antrag.Antworten, &antworten)
}
data := fallDetailData{ data := fallDetailData{
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel, Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit, Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
} }
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID) bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
@@ -115,7 +151,7 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)}) data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
} }
} }
data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil data.CanEntscheiden = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID) entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
if err == nil { if err == nil {
@@ -132,6 +168,24 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
data.Entscheidung = v 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 { if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
@@ -147,13 +201,14 @@ func gueltigkeitFuer(einstufung string) time.Time {
} }
// handleFallEntscheiden speichert die Entscheidung eines/einer // handleFallEntscheiden speichert die Entscheidung eines/einer
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein // Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die // haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein // requireFachebene (404, weil die Seite für sie gar nicht existieren
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden. // soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall
// existiert, nur nicht entscheiden.
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) user := currentUser(r)
if user.Role != "verantwortlicher" { if !hatEntscheidungsrecht(user.Role) {
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden) http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
return return
} }
@@ -197,7 +252,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID, AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: entscheidungTyp, Begruendung: begruendung, Entscheidung: entscheidungTyp, Begruendung: begruendung,
} }
var werkzeugName string
if istGenehmigung { if istGenehmigung {
zulaessig := false zulaessig := false
for _, id := range bewertung.ZulaessigeWerkzeuge { for _, id := range bewertung.ZulaessigeWerkzeuge {
@@ -215,7 +269,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return return
} }
werkzeugName = werkzeug.Name
in.WerkzeugID = &werkzeugID in.WerkzeugID = &werkzeugID
in.WerkzeugSnapshot = &werkzeug in.WerkzeugSnapshot = &werkzeug
gueltigBis := gueltigkeitFuer(bewertung.Einstufung) gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
@@ -227,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) http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return 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 { if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return return
@@ -235,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) http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return 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 { if istGenehmigung {
abteilungName := "" if err := s.registriereGenehmigung(r.Context(), antrag, entscheidung, bewertung); err != nil {
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 {
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
@@ -263,3 +330,67 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther) 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

@@ -87,6 +87,28 @@ func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
} }
} }
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
func TestFallDetailZeigtFragebogenAntworten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
}
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
}
}
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) { func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
fs := newFakeStore() fs := newFakeStore()
s := newServer(t, fs) s := newServer(t, fs)

View File

@@ -0,0 +1,493 @@
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
// und CLAUDE.md. Drei Seitengruppen:
// - Admin (Ebene 4): Genehmiger-Rollen anlegen/Mitglieder zuweisen,
// Freigabe-Regeln definieren ("wenn Bedingung X, dann Freigabe
// durch Rolle Y nötig").
// - Jeder angemeldete Nutzer: "Meine Freigaben" — eigene ausstehende
// Freigabeschritte, unabhängig von app_user.role (eine
// Genehmiger-Rolle ist orthogonal zur Zugriffsrolle).
package web
import (
"context"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
// standardGenehmigerRollen wird bei jeder neuen Firma automatisch angelegt
// (ohne Mitglieder/Freigabe-Regeln) — der Admin muss nur noch Personen
// zuordnen und bei Bedarf Freigabe-Regeln ergänzen, statt bei null
// anzufangen. Die Beschreibung ist reine Orientierungshilfe im UI; welche
// Bedingung welche Rolle tatsächlich auslöst, entscheidet weiterhin jede
// Firma selbst über /verwaltung/freigabe-regeln — das System schreibt
// keine fachliche Bindung vor.
var standardGenehmigerRollen = []struct {
Name string
Beschreibung string
}{
{"Datenschutzbeauftragter", "Prüft Anträge mit besonderen Datenschutzanforderungen, z. B. wenn eine Datenschutz-Folgenabschätzung erforderlich ist."},
{"Geschäftsführer", "Trifft die unternehmerische Entscheidung bei besonders risikobehafteten Vorhaben."},
{"KI-Manager", "Bewertet den Antrag fachlich."},
{"CISO", "Prüft den Antrag aus Sicherheitssicht (Auditor)."},
}
// seedStandardGenehmigerRollen wird direkt nach dem Anlegen einer neuen
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage).
func (s *Server) seedStandardGenehmigerRollen(ctx context.Context, accountID string) error {
for _, r := range standardGenehmigerRollen {
if _, err := s.store.CreateGenehmigerRolle(ctx, accountID, r.Name, r.Beschreibung); err != nil {
return err
}
}
return nil
}
// ─── Admin: Genehmiger-Rollen ───────────────────────────────────────
type genehmigerRolleItem struct {
ID string
Name string
Beschreibung string
Mitglieder []nutzerOption
}
type nutzerOption struct {
ID string
Email string
}
type genehmigerRollenListeData struct {
Title string
Nav navData
Rollen []genehmigerRolleItem
Nutzer []nutzerOption
Error string
}
func (s *Server) handleGenehmigerRollenListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
users, err := s.store.ListUsersForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := genehmigerRollenListeData{Title: "Genehmiger-Rollen", Nav: navFor(r)}
for _, u := range users {
data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email})
}
for _, g := range rollen {
item := genehmigerRolleItem{ID: g.ID, Name: g.Name, Beschreibung: g.Beschreibung}
mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID)
if err == nil {
for _, m := range mitglieder {
item.Mitglieder = append(item.Mitglieder, nutzerOption{ID: m.UserID, Email: m.Email})
}
}
data.Rollen = append(data.Rollen, item)
}
if err := s.templates.ExecuteTemplate(w, "genehmiger-rollen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleGenehmigerRolleCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
return
}
beschreibung := strings.TrimSpace(r.FormValue("beschreibung"))
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name, beschreibung); err != nil {
http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
// handleGenehmigerRolleDelete entfernt eine Genehmiger-Rolle. Solange
// noch Mitglieder zugewiesen sind oder eine Freigabe-Regel darauf
// verweist, schlägt das per Fremdschlüssel fehl — bewusst kein CASCADE,
// ein stillschweigendes Mit-Löschen wirksamer Freigabe-Regeln wäre
// überraschend und sicherheitsrelevant.
func (s *Server) handleGenehmigerRolleDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
rolle, err := s.store.GetGenehmigerRolle(r.Context(), id)
if err != nil || rolle.AccountID != currentUser(r).AccountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteGenehmigerRolle(r.Context(), id); err != nil {
http.Error(w, "Genehmiger-Rolle wird noch verwendet (Mitglieder oder Freigabe-Regeln) und kann nicht gelöscht werden", http.StatusBadRequest)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
func (s *Server) handleGenehmigerRolleMitgliedHinzufuegen(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
rolleID := r.PathValue("id")
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
if err != nil || rolle.AccountID != accountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
userID := r.FormValue("user_id")
target, err := s.store.GetUser(r.Context(), userID)
if err != nil || target.AccountID != accountID {
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.AddNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
http.Error(w, "Zuweisung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
func (s *Server) handleGenehmigerRolleMitgliedEntfernen(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
rolleID := r.PathValue("id")
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
if err != nil || rolle.AccountID != accountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
userID := r.PathValue("userID")
if err := s.store.RemoveNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
http.Error(w, "Entfernen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
}
// ─── Admin: Freigabe-Regeln ─────────────────────────────────────────
// bedingungOption ist ein Auswahlpunkt im Freigabe-Regel-Formular —
// Value kodiert "typ:wert" (z. B. "anforderung:dsfa_erforderlich"),
// damit ein einziges <select> ohne JavaScript reicht statt zweier
// voneinander abhängiger Dropdowns.
type bedingungOption struct {
Value string
Label string
}
type bedingungGruppe struct {
Label string
Optionen []bedingungOption
}
type freigabeRegelItem struct {
ID string
BedingungLabel string
GenehmigerRolle string
}
type freigabeRegelnListeData struct {
Title string
Nav navData
Regeln []freigabeRegelItem
BedingungGruppen []bedingungGruppe
RollenOptionen []store.GenehmigerRolle
Error string
}
// bedingungGruppenAusRegelwerk baut die Freigabe-Regel-Bedingungsauswahl
// aus dem tatsächlich geladenen Regelwerk (nicht frei erfunden) — jede
// Anforderungs-/Einstufungs-/Datenklasse-ID, die das System ohnehin
// kennt, ist eine mögliche Bedingung.
func (s *Server) bedingungGruppenAusRegelwerk() []bedingungGruppe {
var anforderungen []bedingungOption
for _, a := range s.regelwerk.Anforderungen.Anforderungen {
label := a.ID
if a.Beschreibung != "" {
label = a.Beschreibung
}
anforderungen = append(anforderungen, bedingungOption{Value: "anforderung:" + a.ID, Label: label})
}
var einstufungen []bedingungOption
for _, e := range s.regelwerk.Einstufung.Stufen {
einstufungen = append(einstufungen, bedingungOption{Value: "einstufung:" + e.ID, Label: e.ID})
}
var datenklassen []bedingungOption
for _, d := range s.regelwerk.Datenklasse.Stufen {
datenklassen = append(datenklassen, bedingungOption{Value: "datenklasse:" + d.ID, Label: d.ID})
}
return []bedingungGruppe{
{Label: "Anforderung", Optionen: anforderungen},
{Label: "Einstufung", Optionen: einstufungen},
{Label: "Datenklasse", Optionen: datenklassen},
}
}
func (s *Server) handleFreigabeRegelnListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
rollenName := map[string]string{}
for _, g := range rollen {
rollenName[g.ID] = g.Name
}
data := freigabeRegelnListeData{
Title: "Freigabe-Regeln", Nav: navFor(r),
BedingungGruppen: s.bedingungGruppenAusRegelwerk(), RollenOptionen: rollen,
}
for _, regel := range regeln {
data.Regeln = append(data.Regeln, freigabeRegelItem{
ID: regel.ID,
BedingungLabel: bedingungTypLabel(regel.BedingungTyp) + ": " + regel.BedingungWert,
GenehmigerRolle: rollenName[regel.GenehmigerRolleID],
})
}
if err := s.templates.ExecuteTemplate(w, "freigabe-regeln-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func bedingungTypLabel(typ string) string {
switch typ {
case "anforderung":
return "Anforderung"
case "einstufung":
return "Einstufung"
case "datenklasse":
return "Datenklasse"
default:
return typ
}
}
func (s *Server) handleFreigabeRegelCreate(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
teile := strings.SplitN(r.FormValue("bedingung"), ":", 2)
rolleID := r.FormValue("genehmiger_rolle_id")
if len(teile) != 2 || teile[0] == "" || teile[1] == "" || rolleID == "" {
http.Error(w, "Bedingung und Genehmiger-Rolle sind Pflicht", http.StatusBadRequest)
return
}
typ, wert := teile[0], teile[1]
if typ != "anforderung" && typ != "einstufung" && typ != "datenklasse" {
http.Error(w, "ungültiger Bedingungstyp", http.StatusBadRequest)
return
}
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
if err != nil || rolle.AccountID != accountID {
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
return
}
if _, err := s.store.CreateFreigabeRegel(r.Context(), accountID, typ, wert, rolleID); err != nil {
http.Error(w, "Freigabe-Regel konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
}
func (s *Server) handleFreigabeRegelDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
regel, err := s.store.GetFreigabeRegel(r.Context(), id)
if err != nil || regel.AccountID != currentUser(r).AccountID {
http.Error(w, "Freigabe-Regel nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteFreigabeRegel(r.Context(), id); err != nil {
http.Error(w, "Freigabe-Regel konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
}
// ─── Meine Freigaben (jeder angemeldete Nutzer) ─────────────────────
type freigabeItem struct {
ID string
AntragID string
AntragTitel string
RolleName string
CreatedAt string
}
type freigabenListeData struct {
Title string
Nav navData
Freigaben []freigabeItem
}
// handleFreigabenListe zeigt die eigenen ausstehenden Freigabeschritte
// — unabhängig von app_user.role, denn eine Genehmiger-Rolle ist eine
// orthogonale Freigabe-Funktion, keine Zugriffsrolle.
func (s *Server) handleFreigabenListe(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
schritte, err := s.store.ListAusstehendeFreigabeschritteForUser(r.Context(), user.ID)
if err != nil {
http.Error(w, "Freigaben konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := freigabenListeData{Title: "Meine Freigaben", Nav: navFor(r)}
for _, schritt := range schritte {
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
if err != nil {
continue
}
rolleName := ""
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
rolleName = rolle.Name
}
data.Freigaben = append(data.Freigaben, freigabeItem{
ID: schritt.ID, AntragID: antrag.ID, AntragTitel: antrag.Titel,
RolleName: rolleName, CreatedAt: schritt.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "freigaben-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleFreigabeEntscheiden trägt die Entscheidung einer Person zu
// einem einzelnen Freigabeschritt ein. Genehmigt sie den letzten noch
// ausstehenden Schritt eines Antrags, wird die ursprüngliche Genehmigung
// jetzt final abgeschlossen (Registereintrag). Lehnt sie ab, kippt das
// den gesamten Antrag — alle anderen offenen Schritte desselben Antrags
// werden automatisch mit abgelehnt (KaskadiereAblehnung), und eine neue
// Entscheidung "abgelehnt" übersteuert die ursprüngliche Genehmigung
// (entscheidung ist append-only, siehe CLAUDE.md).
func (s *Server) handleFreigabeEntscheiden(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
id := r.PathValue("id")
schritt, err := s.store.GetFreigabeschritt(r.Context(), id)
if err != nil {
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
return
}
rollen, err := s.store.ListGenehmigerRollenForUser(r.Context(), user.ID)
if err != nil {
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
hatRolle := false
for _, rl := range rollen {
if rl.ID == schritt.GenehmigerRolleID {
hatRolle = true
break
}
}
if !hatRolle {
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
return
}
if schritt.Status != "ausstehend" {
http.Error(w, "dieser Freigabeschritt wurde bereits entschieden", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
entscheidungTyp := r.FormValue("entscheidung")
kommentar := r.FormValue("kommentar")
if entscheidungTyp != "genehmigt" && entscheidungTyp != "abgelehnt" {
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
return
}
if _, err := s.store.EntscheideFreigabeschritt(r.Context(), id, entscheidungTyp, user.ID, kommentar); err != nil {
http.Error(w, "Freigabeschritt konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
if err != nil {
http.Error(w, "Antrag nicht gefunden", http.StatusInternalServerError)
return
}
rolle, _ := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID)
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "freigabeschritt_entschieden", "antrag", antrag.ID, rolle.Name+": "+entscheidungTyp); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Bewertung nicht gefunden", http.StatusInternalServerError)
return
}
if entscheidungTyp == "abgelehnt" {
if err := s.store.KaskadiereAblehnung(r.Context(), antrag.ID, id); err != nil {
http.Error(w, "Kaskadierung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
begruendung := "Freigabe durch " + rolle.Name + " wurde verweigert."
if kommentar != "" {
begruendung += " Kommentar: " + kommentar
}
if _, err := s.store.CreateEntscheidung(r.Context(), store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: "abgelehnt", Begruendung: begruendung,
}); err != nil {
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
} else {
alle, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Freigabeschritte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
vollstaendig := true
for _, fs := range alle {
if fs.Status != "genehmigt" {
vollstaendig = false
break
}
}
if vollstaendig {
letzteEntscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Entscheidung nicht gefunden", http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.registriereGenehmigung(r.Context(), antrag, letzteEntscheidung, bewertung); err != nil {
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
}
}
http.Redirect(w, r, "/freigaben", http.StatusSeeOther)
}

View File

@@ -0,0 +1,283 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/web"
)
// besondereKategorieAntragForm loest ueber b2=ja die Datenklasse
// besondere_kategorie aus, die laut rules/anforderungen.yaml die
// Anforderung dsfa_erforderlich nach sich zieht — Grundlage fuer die
// Freigabe-Workflow-Tests.
func besondereKategorieAntragForm() url.Values {
return url.Values{
"titel": {"Gesundheitsdaten auswerten"}, "beschreibung": {"KI wertet Gesundheitsdaten aus"},
"ergebnis": {"Auswertung"}, "haeufigkeit": {"gelegentlich"},
"b1": {"nein"}, "b2": {"ja"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
"c4": {"nein"}, "c5": {"ja"},
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
}
}
// seedFreigabeSzenario legt einen Mandanten mit Mitarbeiter (reicht
// einen Antrag ein, der dsfa_erforderlich ausloest), Verantwortlichem
// und einem zentralen Werkzeug an — die Grundlage, auf der die
// einzelnen Freigabe-Workflow-Tests aufbauen.
func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, "Freigabe-Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter-fg@example.com", "mitarbeiter")
werkzeugForm := url.Values{
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
"training_standard": {"on"}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
}
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber FG", "betreiber-fg@example.com", "betreiber")
if resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm); resp.Code != http.StatusSeeOther {
t.Fatalf("Werkzeug anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
resp := postForm(t, s, mitarbeiterCookie, "/antraege", besondereKategorieAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
for id, a := range fs.antraege {
if a.AccountID == acc.ID {
antragID = id
}
}
if antragID == "" {
t.Fatal("kein Antrag im Mandanten gefunden")
}
verantwortlicherCookie = seedUserInAccount(t, fs, acc.ID, "verantwortlicher-fg@example.com", "verantwortlicher")
return acc.ID, antragID, verantwortlicherCookie
}
// werkzeugIDFor findet die ID des einzigen im fakeStore angelegten
// Werkzeugs — fuer die Genehmigungs-Formulare in diesen Tests reicht ein
// einziges zentrales Werkzeug.
func werkzeugIDFor(fs *fakeStore, name string) string {
for id, w := range fs.werkzeuge {
if w.Name == name {
return id
}
}
return ""
}
func TestOhneFreigabeRegelnVerhaeltSichWieVorher(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
}
if fs.antraege[antragID].Status != "entschieden" {
t.Fatalf("Status = %q, want entschieden (keine Freigabe-Regel konfiguriert)", fs.antraege[antragID].Status)
}
regEintraege := fs.registereintraege[fs.antraege[antragID].AccountID]
if len(regEintraege) != 1 {
t.Fatalf("erwartet 1 Registereintrag ohne Freigabe-Regeln, got %d", len(regEintraege))
}
}
func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err)
}
if _, err := fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID); err != nil {
t.Fatalf("CreateFreigabeRegel: %v", err)
}
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb@example.com", "mitarbeiter")
dsbUser, err := fs.GetUserByEmail(context.Background(), "dsb@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if err := fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
}
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
}
if got := fs.antraege[antragID].Status; got != "wartet_auf_freigabe" {
t.Fatalf("Status = %q, want wartet_auf_freigabe", got)
}
if len(fs.registereintraege[accountID]) != 0 {
t.Fatalf("erwartet noch keinen Registereintrag vor vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
}
listResp := getWithCookie(t, s, dsbCookie, "/freigaben")
if listResp.Code != http.StatusOK || !strings.Contains(listResp.Body.String(), "Gesundheitsdaten auswerten") {
t.Fatalf("Meine Freigaben zeigt den Antrag nicht, status=%d body=%s", listResp.Code, listResp.Body.String())
}
var schrittID string
for id, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID {
schrittID = id
}
}
if schrittID == "" {
t.Fatal("kein Freigabeschritt fuer den Antrag gefunden")
}
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
if decideResp.Code != http.StatusSeeOther {
t.Fatalf("freigabe entscheiden: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
}
if got := fs.antraege[antragID].Status; got != "entschieden" {
t.Fatalf("Status nach vollstaendiger Freigabe = %q, want entschieden", got)
}
if len(fs.registereintraege[accountID]) != 1 {
t.Fatalf("erwartet 1 Registereintrag nach vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
}
}
func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung", "")
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID)
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb2@example.com", "mitarbeiter")
dsbUser, _ := fs.GetUserByEmail(context.Background(), "dsb2@example.com")
fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID)
seedUserInAccount(t, fs, accountID, "gf@example.com", "mitarbeiter")
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
}
var dsbSchrittID string
for id, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID && fsr.GenehmigerRolleID == dsb.ID {
dsbSchrittID = id
}
}
if dsbSchrittID == "" {
t.Fatal("kein DSB-Freigabeschritt gefunden")
}
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+dsbSchrittID+"/entscheiden", url.Values{
"entscheidung": {"abgelehnt"}, "kommentar": {"Nicht ausreichend pseudonymisiert"},
})
if decideResp.Code != http.StatusSeeOther {
t.Fatalf("freigabe ablehnen: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
}
if got := fs.antraege[antragID].Status; got != "entschieden" {
t.Fatalf("Status nach Ablehnung = %q, want entschieden", got)
}
letzte := fs.entscheidungen[antragID]
if len(letzte) == 0 || letzte[len(letzte)-1].Entscheidung != "abgelehnt" {
t.Fatalf("letzte Entscheidung = %+v, want abgelehnt", letzte)
}
for _, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID && fsr.Status == "ausstehend" {
t.Fatalf("erwartet keine ausstehenden Freigabeschritte mehr nach Ablehnung, gefunden: %+v", fsr)
}
}
if len(fs.registereintraege[accountID]) != 0 {
t.Fatalf("erwartet keinen Registereintrag nach Ablehnung, got %d", len(fs.registereintraege[accountID]))
}
}
func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter", "")
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")
postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
})
var schrittID string
for id, fsr := range fs.freigabeschritte {
if fsr.AntragID == antragID {
schrittID = id
}
}
resp := postForm(t, s, fremdCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 fuer eine Person ohne die noetige Genehmiger-Rolle", resp.Code)
}
}
func TestGenehmigerRolleCRUD(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "GR-Mandant", "admin-gr@example.com", "admin")
admin, err := fs.GetUserByEmail(context.Background(), "admin-gr@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
resp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen", url.Values{"name": {"Geschaeftsfuehrung"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
var rolleID string
for id, g := range fs.genehmigerRollen {
if g.Name == "Geschaeftsfuehrung" {
rolleID = id
}
}
if rolleID == "" {
t.Fatal("Genehmiger-Rolle wurde nicht angelegt")
}
assignResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder", url.Values{"user_id": {admin.ID}})
if assignResp.Code != http.StatusSeeOther {
t.Fatalf("zuweisen: status = %d, body: %s", assignResp.Code, assignResp.Body.String())
}
list := getWithCookie(t, s, adminCookie, "/verwaltung/genehmiger-rollen")
if !strings.Contains(list.Body.String(), "admin-gr@example.com") {
t.Errorf("erwartet das zugewiesene Mitglied auf der Liste, got: %s", list.Body.String())
}
removeResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder/"+admin.ID+"/entfernen", url.Values{})
if removeResp.Code != http.StatusSeeOther {
t.Fatalf("entfernen: status = %d, body: %s", removeResp.Code, removeResp.Body.String())
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
package web
// weltLaenderliste ist die kontrollierte Auswahl für "Verarbeitungsländer"
// im Werkzeugkatalog — eine echte Länderliste statt grober Eimer wie
// "EU"/"USA"/"gemischt" (die USA sind DSGVO-rechtlich bereits ein
// Drittland wie jedes andere, siehe CLAUDE.md). Enthält zusätzlich zwei
// Pseudo-Einträge für Fälle, in denen der Anbieter selbst keinen
// einzelnen Staat zusichert bzw. keine Cloud-Verarbeitung stattfindet.
var weltLaenderliste = []string{
"EU-Region (kein einzelnes Land benannt)",
"On-Premise (selbst gehostet)",
"Afghanistan", "Ägypten", "Albanien", "Algerien", "Andorra", "Angola",
"Antigua und Barbuda", "Äquatorialguinea", "Argentinien", "Armenien",
"Aserbaidschan", "Äthiopien", "Australien", "Bahamas", "Bahrain",
"Bangladesch", "Barbados", "Belgien", "Belize", "Benin", "Bhutan",
"Bolivien", "Bosnien und Herzegowina", "Botsuana", "Brasilien",
"Brunei", "Bulgarien", "Burkina Faso", "Burundi", "Chile", "China",
"Costa Rica", "Dänemark", "Deutschland", "Dominica",
"Dominikanische Republik", "Dschibuti", "Ecuador", "El Salvador",
"Elfenbeinküste", "Eritrea", "Estland", "Eswatini", "Fidschi",
"Finnland", "Frankreich", "Gabun", "Gambia", "Georgien", "Ghana",
"Grenada", "Griechenland", "Guatemala", "Guinea", "Guinea-Bissau",
"Guyana", "Haiti", "Honduras", "Indien", "Indonesien", "Irak",
"Iran", "Irland", "Island", "Israel", "Italien", "Jamaika", "Japan",
"Jemen", "Jordanien", "Kambodscha", "Kamerun", "Kanada", "Kap Verde",
"Kasachstan", "Katar", "Kenia", "Kirgisistan", "Kiribati",
"Kolumbien", "Komoren", "Kongo (Republik)",
"Kongo (Demokratische Republik)", "Kosovo", "Kroatien", "Kuba",
"Kuwait", "Laos", "Lesotho", "Lettland", "Libanon", "Liberia",
"Libyen", "Liechtenstein", "Litauen", "Luxemburg", "Madagaskar",
"Malawi", "Malaysia", "Malediven", "Mali", "Malta", "Marokko",
"Marshallinseln", "Mauretanien", "Mauritius", "Mexiko",
"Mikronesien", "Moldau", "Monaco", "Mongolei", "Montenegro",
"Mosambik", "Myanmar", "Namibia", "Nauru", "Nepal", "Neuseeland",
"Nicaragua", "Niederlande", "Niger", "Nigeria", "Nordkorea",
"Nordmazedonien", "Norwegen", "Oman", "Österreich", "Osttimor",
"Pakistan", "Palau", "Palästina", "Panama", "Papua-Neuguinea",
"Paraguay", "Peru", "Philippinen", "Polen", "Portugal", "Ruanda",
"Rumänien", "Russland", "Salomonen", "Sambia", "Samoa",
"San Marino", "São Tomé und Príncipe", "Saudi-Arabien", "Schweden",
"Schweiz", "Senegal", "Serbien", "Seychellen", "Sierra Leone",
"Simbabwe", "Singapur", "Slowakei", "Slowenien", "Somalia",
"Spanien", "Sri Lanka", "St. Kitts und Nevis", "St. Lucia",
"St. Vincent und die Grenadinen", "Südafrika", "Sudan", "Südkorea",
"Südsudan", "Suriname", "Syrien", "Tadschikistan", "Taiwan",
"Tansania", "Thailand", "Togo", "Tonga", "Trinidad und Tobago",
"Tschad", "Tschechien", "Tunesien", "Türkei", "Turkmenistan",
"Tuvalu", "Uganda", "Ukraine", "Ungarn", "Uruguay", "USA",
"Usbekistan", "Vanuatu", "Vatikanstadt", "Venezuela",
"Vereinigte Arabische Emirate", "Vereinigtes Königreich", "Vietnam",
"Zentralafrikanische Republik", "Zypern",
}

View File

@@ -0,0 +1,98 @@
// Löschfristen je Datenklasse (Migration 0020) — schließt rules/OPEN.md,
// Punkt 4. Die Tageswerte sind KEINE gesetzliche Vorgabe (die DSGVO
// nennt keine festen Fristen, nur den Grundsatz "so lange wie für den
// Zweck nötig", Art. 5 Abs. 1 lit. e) — deshalb pro Mandant einstellbar,
// vom Datenschutzbeauftragten hinterlegt, mit risikogestaffelten
// Vorschlagswerten vorbelegt. Wie alle anderen /verwaltung/*-Seiten
// bewusst admin-only (konsistent mit Nutzer-/Abteilungs-/Genehmiger-
// Rollen-Verwaltung) statt an den Namen einer Genehmiger-Rolle
// gekoppelt — Genehmiger-Rollen sind laut Architektur explizit ohne
// eigenes Zugriffsrecht (siehe CLAUDE.md, Freigabe-Workflow).
package web
import (
"context"
"net/http"
"strconv"
)
// standardLoeschfristen sind risikogestaffelte Vorschlagswerte, mit
// denen jede neue Firma vorbelegt wird — je sensibler die Datenklasse,
// desto kürzer die vorgeschlagene Frist. Frei editierbar unter
// /verwaltung/loeschfristen, keine Rechtsvorgabe.
var standardLoeschfristen = []struct {
DatenklasseID string
MaxTage int
}{
{"oeffentlich", 365},
{"intern", 180},
{"auftragsdaten", 90},
{"personenbezogen", 90},
{"berufsgeheimnis", 30},
{"besondere_kategorie", 30},
}
// seedStandardLoeschfristen wird direkt nach dem Anlegen einer neuen
// Firma aufgerufen (öffentliche Registrierung und Betreiber-Firmenanlage),
// analog zu seedStandardGenehmigerRollen.
func (s *Server) seedStandardLoeschfristen(ctx context.Context, accountID string) error {
for _, l := range standardLoeschfristen {
if _, err := s.store.UpsertLoeschfristEinstellung(ctx, accountID, l.DatenklasseID, l.MaxTage); err != nil {
return err
}
}
return nil
}
type loeschfristZeile struct {
DatenklasseID string
MaxTage int
}
type loeschfristenListeData struct {
Title string
Nav navData
Zeilen []loeschfristZeile
Error string
Gespeichert bool
}
func (s *Server) handleLoeschfristenListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
einstellungen, err := s.store.ListLoeschfristEinstellungenForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Löschfristen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := loeschfristenListeData{Title: "Löschfristen", Nav: navFor(r), Gespeichert: r.URL.Query().Get("gespeichert") == "1"}
for _, e := range einstellungen {
data.Zeilen = append(data.Zeilen, loeschfristZeile{DatenklasseID: e.DatenklasseID, MaxTage: e.MaxTage})
}
if err := s.templates.ExecuteTemplate(w, "loeschfristen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleLoeschfristenSpeichern(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := currentUser(r).AccountID
for _, l := range standardLoeschfristen {
raw := r.FormValue("max_tage_" + l.DatenklasseID)
if raw == "" {
continue
}
maxTage, err := strconv.Atoi(raw)
if err != nil || maxTage < 0 {
http.Error(w, "ungültiger Wert für "+l.DatenklasseID, http.StatusBadRequest)
return
}
if _, err := s.store.UpsertLoeschfristEinstellung(r.Context(), accountID, l.DatenklasseID, maxTage); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/verwaltung/loeschfristen?gespeichert=1", http.StatusSeeOther)
}

View File

@@ -0,0 +1,103 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge", werkzeugForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
}
var id string
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
for wid, w := range fs.werkzeuge {
if w.AccountID != nil && *w.AccountID == admin.AccountID {
id = wid
}
}
if id == "" {
t.Fatal("expected an eigenes werkzeug to exist")
}
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
}
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
}
updateForm := werkzeugForm()
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
if updateResp.Code != http.StatusSeeOther {
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
}
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
}
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusSeeOther {
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
}
if _, ok := fs.werkzeuge[id]; ok {
t.Error("expected the eigenes werkzeug to be deleted")
}
}
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
}
}
func TestAdminCannotEditFremdesEigenesWerkzeug(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
postForm(t, s, adminA, "/verwaltung/werkzeuge", werkzeugForm())
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
var id string
for wid, w := range fs.werkzeuge {
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
id = wid
}
}
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
}
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusNotFound {
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
}
}

View File

@@ -1,26 +1,57 @@
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md, // Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für // Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das // sich sperren, ohne den zentralen Katalog selbst zu verändern (das
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go). // bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go),
// UND eigene, mandantenspezifische Katalogeinträge pflegen
// (`werkzeug.account_id` = der eigene Account) — dieselbe
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
package web package web
import "net/http" import (
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
type mandantWerkzeugItem struct { type mandantWerkzeugItem struct {
ID string ID string
Name string Name string
Anbieter string Anbieter string
Gesperrt bool 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
Verarbeitungslaender string
LetztePruefung string
} }
type mandantWerkzeugListeData struct { type mandantWerkzeugListeData struct {
Title string Title string
Nav navData Nav navData
Werkzeuge []mandantWerkzeugItem Werkzeuge []mandantWerkzeugItem
Filter werkzeugFilter
ZweckeOptionen []string
Pagination pagination
EigeneWerkzeuge []eigenesWerkzeugItem
} }
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des // handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag. // eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
// Löschen.
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID accountID := currentUser(r).AccountID
zentral, err := s.store.ListZentraleWerkzeuge(r.Context()) zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
@@ -33,15 +64,49 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Werkzeuge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
gesperrt := make(map[string]bool, len(sperren)) gesperrt := make(map[string]bool, len(sperren))
for _, sp := range sperren { for _, sp := range sperren {
gesperrt[sp.WerkzeugID] = true gesperrt[sp.WerkzeugID] = true
} }
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)} filter := werkzeugFilterFromRequest(r)
var gefiltert []store.Werkzeug
for _, wz := range zentral { for _, wz := range zentral {
if filter.matches(wz) {
gefiltert = append(gefiltert, wz)
}
}
pag := paginationFromRequest(r, len(gefiltert))
seite := paginate(gefiltert, pag)
data := mandantWerkzeugListeData{
Title: "Werkzeugkatalog", Nav: navFor(r),
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
}
for _, wz := range seite {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{ data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID], ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
Gesperrt: gesperrt[wz.ID],
})
}
for _, wz := range eigene {
if wz.AccountID == nil {
continue // zentral, oben schon gelistet
}
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
}) })
} }
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil { if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
@@ -49,6 +114,109 @@ 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",
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)
}
}
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
func (s *Server) handleMandantWerkzeugCreate(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
in, errMsg := werkzeugInputFromForm(r, &accountID)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
// anderen Mandanten.
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
accountID := currentUser(r).AccountID
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
// Katalogeintrag.
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
accountID := currentUser(r).AccountID
wz, err := s.store.GetWerkzeug(r.Context(), id)
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in, errMsg := werkzeugInputFromForm(r, &accountID)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
accountID := currentUser(r).AccountID
wz, err := s.store.GetWerkzeug(r.Context(), id)
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag // handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt // für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für // unverändert, er verschwindet nur aus ListWerkzeugeForAccount für

View File

@@ -12,44 +12,85 @@ const sessionCookieName = "deklarix_session"
type contextKey int type contextKey int
const userContextKey contextKey = iota const (
userContextKey contextKey = iota
impersonatorContextKey
)
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert, // authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false), // nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes // wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit // Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. // requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
func (s *Server) authenticate(r *http.Request) (store.User, bool) { // zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
// sonst leer.
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
cookie, err := r.Cookie(sessionCookieName) cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" { if err != nil || cookie.Value == "" {
return store.User{}, false return store.User{}, "", false
} }
sess, err := s.store.GetSession(r.Context(), cookie.Value) sess, err := s.store.GetSession(r.Context(), cookie.Value)
if err != nil { if err != nil {
return store.User{}, false return store.User{}, "", false
} }
if time.Now().After(sess.ExpiresAt) { if time.Now().After(sess.ExpiresAt) {
return store.User{}, false return store.User{}, "", false
} }
user, err := s.store.GetUser(r.Context(), sess.UserID) user, err := s.store.GetUser(r.Context(), sess.UserID)
if err != nil || !user.Active { if err != nil || !user.Active {
return store.User{}, false return store.User{}, "", false
}
impersonator := ""
if sess.ImpersonatedByUserID != nil {
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
impersonator = betreiber.Email
}
}
return user, impersonator, true
}
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
ctx := context.WithValue(r.Context(), userContextKey, user)
if impersonator != "" {
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
}
return r.WithContext(ctx)
}
// withTenantScope führt next innerhalb einer Postgres-Transaktion aus,
// die die Sitzungsvariablen für Row-Level-Security setzt (siehe
// store.WithTenantScope) — jede require*-Middleware ruft das anstelle
// eines direkten next(w, r) auf, damit JEDE Store-Anfrage innerhalb
// dieses Requests automatisch auf den angemeldeten Mandanten begrenzt
// ist, nicht nur die, die ein Handler zufällig selbst noch mal prüft.
// Ein Fehler hier ist ein Infrastrukturproblem (z. B. DB down), kein
// normaler Handler-Fehlerpfad — 500 statt eines spezifischeren Codes.
func (s *Server) withTenantScope(w http.ResponseWriter, r *http.Request, user store.User, next http.HandlerFunc) {
err := s.store.WithTenantScope(r.Context(), user.AccountID, user.Role == "betreiber", func(ctx context.Context) error {
next(w, r.WithContext(ctx))
return nil
})
if err != nil {
http.Error(w, "Datenbankfehler: "+err.Error(), http.StatusInternalServerError)
} }
return user, true
} }
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht // requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment). // es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r) user, impersonator, ok := s.authenticate(r)
if !ok { if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
} }
} }
@@ -59,12 +100,12 @@ func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect. // mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r) user, impersonator, ok := s.authenticate(r)
if !ok { if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized) http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return return
} }
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
} }
} }
@@ -76,7 +117,7 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
// verraten, dass unter dieser URL überhaupt etwas existiert. // verraten, dass unter dieser URL überhaupt etwas existiert.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r) user, impersonator, ok := s.authenticate(r)
if !ok { if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
@@ -85,30 +126,37 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound) http.Error(w, "nicht gefunden", http.StatusNotFound)
return return
} }
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
} }
} }
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und // requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe // Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der // CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
// Seite tatsächlich entschieden werden darf, prüft der einzelne // will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei // verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
// — ohne das könnte eine frisch registrierte Firma mit nur einem
// admin-Login keinen einzigen eingereichten Antrag sehen oder
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein // requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter") // angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt. // nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r) user, impersonator, ok := s.authenticate(r)
if !ok { if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
if user.Role != "verantwortlicher" && user.Role != "pruefer" { if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound) http.Error(w, "nicht gefunden", http.StatusNotFound)
return return
} }
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
} }
} }
@@ -117,7 +165,7 @@ func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle. // requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r) user, impersonator, ok := s.authenticate(r)
if !ok { if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
@@ -126,7 +174,7 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
http.Error(w, "nicht gefunden", http.StatusNotFound) http.Error(w, "nicht gefunden", http.StatusNotFound)
return return
} }
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) s.withTenantScope(w, withAuthContext(r, user, impersonator), user, next)
} }
} }
@@ -135,9 +183,10 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
// currentUser zu geben — die Navigation braucht nur, ob ein // currentUser zu geben — die Navigation braucht nur, ob ein
// Plattform-Link gezeigt werden soll. // Plattform-Link gezeigt werden soll.
type navData struct { type navData struct {
IsBetreiber bool IsBetreiber bool
IsFachebene bool IsFachebene bool
IsAdmin bool IsAdmin bool
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
} }
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage. // navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
@@ -147,8 +196,10 @@ func navFor(r *http.Request) navData {
role := currentUser(r).Role role := currentUser(r).Role
return navData{ return navData{
IsBetreiber: role == "betreiber", IsBetreiber: role == "betreiber",
IsFachebene: role == "verantwortlicher" || role == "pruefer", // admin sieht die Fachebene mit, siehe requireFachebene.
IsAdmin: role == "admin", IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
IsAdmin: role == "admin",
Impersonator: currentImpersonator(r),
} }
} }
@@ -163,3 +214,10 @@ func currentUser(r *http.Request) store.User {
} }
return user return user
} }
// currentImpersonator liefert die E-Mail des Betreibers, falls die
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
func currentImpersonator(r *http.Request) string {
v, _ := r.Context().Value(impersonatorContextKey).(string)
return v
}

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,103 @@
// Passwort-Zurücksetzen (Ebene 1, öffentlich) — nutzt internal/mail für
// den Versand. Wie beim Login verrät keine Fehlermeldung, ob eine
// E-Mail-Adresse überhaupt existiert (User-Enumeration-Schutz): die
// Anfrage-Seite zeigt immer dieselbe Erfolgsmeldung, unabhängig davon,
// ob tatsächlich eine Mail verschickt wurde.
package web
import (
"fmt"
"log"
"net/http"
"github.com/netcell-it/deklarix/internal/auth"
)
const passwortVergessenHinweis = "Falls diese E-Mail-Adresse bei uns registriert ist, wurde ein Link zum Zurücksetzen verschickt."
func (s *Server) handlePasswortVergessenForm(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen"})
}
func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Error: "ungültiges Formular"})
return
}
email := r.FormValue("email")
user, err := s.store.GetUserByEmail(r.Context(), email)
if err == nil && user.Active {
token, tokenErr := auth.NewSessionToken()
if tokenErr == nil {
if _, createErr := s.store.CreatePasswordResetToken(r.Context(), user.ID, token); createErr == nil {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
body := "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n" + link + "\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
// (keine Enumeration über unterschiedliches Verhalten bei
// Versandfehlern), aber serverseitig geloggt statt
// verschluckt — sonst bleibt ein dauerhaft falsch
// konfigurierter SMTP-Server unbemerkt.
if err := s.mailer.Send(user.Email, "Deklarix — Passwort zurücksetzen", body); err != nil {
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
}
}
}
}
s.renderAuthPage(w, "passwort-vergessen", authPageData{Title: "Passwort vergessen", Message: passwortVergessenHinweis})
}
type passwortZuruecksetzenData struct {
Title string
Token string
Error string
}
func (s *Server) handlePasswortZuruecksetzenForm(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if _, err := s.store.GetValidPasswordResetToken(r.Context(), token); err != nil {
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
return
}
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token}); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handlePasswortZuruecksetzen(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
resetToken, err := s.store.GetValidPasswordResetToken(r.Context(), token)
if err != nil {
http.Error(w, "Link ist ungültig oder abgelaufen", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
s.renderPasswortZuruecksetzenError(w, token, "ungültiges Formular")
return
}
passwordHash, err := auth.HashPassword(r.FormValue("password"))
if err != nil {
s.renderPasswortZuruecksetzenError(w, token, err.Error())
return
}
if err := s.store.SetUserPassword(r.Context(), resetToken.UserID, passwordHash); err != nil {
s.renderPasswortZuruecksetzenError(w, token, "Passwort konnte nicht gesetzt werden")
return
}
if err := s.store.MarkPasswordResetTokenUsed(r.Context(), resetToken.ID); err != nil {
s.renderPasswortZuruecksetzenError(w, token, "Passwort wurde gesetzt, Token konnte aber nicht als verwendet markiert werden")
return
}
http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther)
}
func (s *Server) renderPasswortZuruecksetzenError(w http.ResponseWriter, token, msg string) {
if err := s.templates.ExecuteTemplate(w, "passwort-zuruecksetzen", passwortZuruecksetzenData{Title: "Neues Passwort setzen", Token: token, Error: msg}); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,94 @@
package web_test
import (
"net/http"
"net/url"
"regexp"
"testing"
)
var resetLinkPattern = regexp.MustCompile(`/passwort-zuruecksetzen/([a-f0-9]+)`)
func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
fs := newFakeStore()
s, fm := newServerWithMailer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
"password": {"altes-passwort"},
})
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303", regResp.Code)
}
vergessenResp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"reset@example.com"}})
if vergessenResp.Code != http.StatusOK {
t.Fatalf("passwort-vergessen status = %d, want 200", vergessenResp.Code)
}
if len(fm.Sent) != 1 {
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
}
match := resetLinkPattern.FindStringSubmatch(fm.Sent[0].Body)
if match == nil {
t.Fatalf("kein Reset-Link in Mail-Body gefunden: %q", fm.Sent[0].Body)
}
token := match[1]
getResp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/"+token)
if getResp.Code != http.StatusOK {
t.Fatalf("get reset form status = %d, want 200", getResp.Code)
}
resetResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"neues-passwort-123"}})
if resetResp.Code != http.StatusSeeOther {
t.Fatalf("reset status = %d, want 303, body: %s", resetResp.Code, resetResp.Body.String())
}
// Derselbe Link darf kein zweites Mal funktionieren.
reuseResp := postForm(t, s, nil, "/passwort-zuruecksetzen/"+token, url.Values{"password": {"noch-ein-passwort"}})
if reuseResp.Code != http.StatusNotFound {
t.Fatalf("reuse status = %d, want 404 (Token bereits verwendet)", reuseResp.Code)
}
// Altes Passwort funktioniert nicht mehr.
oldLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"altes-passwort"}})
if oldLoginResp.Code != http.StatusOK {
t.Fatalf("login mit altem Passwort status = %d, want 200 (abgelehnt)", oldLoginResp.Code)
}
// Neues Passwort funktioniert.
newLoginResp := postForm(t, s, nil, "/login", url.Values{"email": {"reset@example.com"}, "password": {"neues-passwort-123"}})
if newLoginResp.Code != http.StatusSeeOther {
t.Fatalf("login mit neuem Passwort status = %d, want 303", newLoginResp.Code)
}
}
func TestPasswortVergessenFuerUnbekannteEmailVerraetNichts(t *testing.T) {
fs := newFakeStore()
s, fm := newServerWithMailer(t, fs)
resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"gibt-es-nicht@example.com"}})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.Code)
}
if len(fm.Sent) != 0 {
t.Fatalf("got %d gesendete Mails für unbekannte E-Mail, want 0", len(fm.Sent))
}
if !containsHinweis(resp.Body.String()) {
t.Fatalf("erwartete generische Erfolgsmeldung, body: %s", resp.Body.String())
}
}
func containsHinweis(body string) bool {
return regexp.MustCompile(`Falls diese E-Mail-Adresse`).MatchString(body)
}
func TestPasswortZuruecksetzenMitUngueltigemTokenGibt404(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := getWithCookie(t, s, nil, "/passwort-zuruecksetzen/nicht-existierendes-token")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.Code)
}
}

View File

@@ -27,9 +27,11 @@ type registerEintragView struct {
} }
type registerListeData struct { type registerListeData struct {
Title string Title string
Nav navData Nav navData
Eintraege []registerEintragView Eintraege []registerEintragView
Suche string
Pagination pagination
} }
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) { 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) http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return 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 { if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }

View File

@@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/netcell-it/deklarix/internal/mail"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
) )
@@ -28,15 +29,34 @@ var staticFS embed.FS
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren // komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen. // einen Fake statt eine echte Datenbank zu brauchen.
type Store interface { type Store interface {
// WithTenantScope/SetTenantScope: siehe internal/store/tenant_scope.go
// (Row-Level-Security-Sitzungskontext). fakeStore implementiert beide
// als reinen Passthrough — Tests laufen ohne echtes Postgres/RLS,
// die Isolation wird dort weiterhin (wie bisher) auf Anwendungsebene
// geprüft; die tatsächliche RLS-Durchsetzung hat eigene Tests gegen
// echtes Postgres, siehe internal/store/tenant_scope_test.go.
WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error
SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error
CreateAccount(ctx context.Context, name string) (store.Account, error) CreateAccount(ctx context.Context, name string) (store.Account, error)
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error) GetAccount(ctx context.Context, id string) (store.Account, error)
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
ListAccounts(ctx context.Context) ([]store.Account, error) ListAccounts(ctx context.Context) ([]store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error) GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error) GetUser(ctx context.Context, id string) (store.User, error)
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
SetUserActive(ctx context.Context, id string, active bool) error SetUserActive(ctx context.Context, id string, active bool) error
SetUserPassword(ctx context.Context, id, passwordHash string) error
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error) GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
@@ -72,6 +92,27 @@ type Store interface {
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]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 // Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
@@ -90,11 +131,15 @@ type Server struct {
store Store store Store
regelwerk Regelwerk regelwerk Regelwerk
templates *template.Template templates *template.Template
mailer mail.Mailer
} }
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und // NewServer erstellt den Server. regelwerk kommt von rules.Load* und
// wird einmal beim Start geladen, nicht pro Request. // wird einmal beim Start geladen, nicht pro Request. mailer ist
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { // 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") tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err) return nil, fmt.Errorf("web: templates parsen: %w", err)
@@ -104,14 +149,23 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
store: st, store: st,
regelwerk: regelwerk, regelwerk: regelwerk,
templates: tmpl, templates: tmpl,
mailer: mailer,
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm) mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister) mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
mux.HandleFunc("POST /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzen)
mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex)) mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList)) mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
@@ -130,15 +184,38 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren)) mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren)) mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe)) mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate)) mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete)) mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe)) mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren)) mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren)) mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
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", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog)) mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe)) mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm)) mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))

View File

@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/netcell-it/deklarix/internal/auth" "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/rules"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web" "github.com/netcell-it/deklarix/internal/web"
@@ -38,24 +39,48 @@ type fakeStore struct {
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
genehmigerRollen map[string]store.GenehmigerRolle
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
freigabeRegeln map[string]store.FreigabeRegel
freigabeschritte map[string]store.Freigabeschritt
passwordResetTokens map[string]store.PasswordResetToken
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
} }
func newFakeStore() *fakeStore { func newFakeStore() *fakeStore {
return &fakeStore{ return &fakeStore{
accounts: map[string]store.Account{}, accounts: map[string]store.Account{},
users: map[string]store.User{}, users: map[string]store.User{},
usersByEmail: map[string]string{}, usersByEmail: map[string]string{},
sessions: map[string]store.Session{}, sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{}, abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{}, antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{}, werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{}, bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{}, entscheidungen: map[string][]store.Entscheidung{},
registereintraege: map[string][]store.Registereintrag{}, registereintraege: map[string][]store.Registereintrag{},
werkzeugSperren: map[string][]store.WerkzeugSperre{}, 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{},
} }
} }
// 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) newID() string { func (f *fakeStore) newID() string {
f.nextID++ f.nextID++
return fmt.Sprintf("id-%d", f.nextID) return fmt.Sprintf("id-%d", f.nextID)
@@ -64,11 +89,47 @@ func (f *fakeStore) newID() string {
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) { func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()} id := f.newID()
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
f.accounts[acc.ID] = acc f.accounts[acc.ID] = acc
return acc, nil return acc, nil
} }
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
acc.Name = name
f.accounts[id] = acc
return acc, nil
}
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, acc := range f.accounts {
if acc.EinladungToken == token {
return acc, nil
}
}
return store.Account{}, store.ErrNotFound
}
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[accountID]
if !ok {
return store.ErrNotFound
}
acc.EinladungToken = newToken
f.accounts[accountID] = acc
return nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) { func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -136,6 +197,73 @@ func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) e
return nil 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) { func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -156,6 +284,14 @@ func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, exp
return sess, nil return sess, nil
} }
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
f.sessions[token] = sess
return sess, nil
}
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) { func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -364,9 +500,9 @@ func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput)
defer f.mu.Unlock() defer f.mu.Unlock()
w := store.Werkzeug{ w := store.Werkzeug{
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter, ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL, Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage, TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen, Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(), LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
} }
f.werkzeuge[w.ID] = w f.werkzeuge[w.ID] = w
@@ -380,10 +516,10 @@ func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.Werk
if !ok { if !ok {
return store.Werkzeug{}, store.ErrNotFound return store.Werkzeug{}, store.ErrNotFound
} }
w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort w.Name, w.Anbieter, w.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now() w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
f.werkzeuge[id] = w f.werkzeuge[id] = w
return w, nil return w, nil
@@ -503,6 +639,208 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account
return f.registereintraege[accountID], nil 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 ─────────────────────────────────────────────────────── // ─── Test-Setup ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben, // loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
@@ -529,11 +867,18 @@ func loadTestRegelwerk(t *testing.T) web.Regelwerk {
func newServer(t *testing.T, fs *fakeStore) *web.Server { func newServer(t *testing.T, fs *fakeStore) *web.Server {
t.Helper() 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 { if err != nil {
t.Fatalf("NewServer: %v", err) t.Fatalf("NewServer: %v", err)
} }
return s return s, fm
} }
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen // seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
@@ -689,6 +1034,53 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
} }
} }
func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
"password": {"ein-sicheres-passwort"},
})
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
var accountID string
for _, u := range fs.users {
if u.Email == "neu2@example.com" {
accountID = u.AccountID
}
}
if accountID == "" {
t.Fatal("neuer Nutzer nicht gefunden")
}
rollen, err := fs.ListGenehmigerRollenForAccount(context.Background(), accountID)
if err != nil {
t.Fatalf("ListGenehmigerRollenForAccount: %v", err)
}
wantNamen := []string{"Datenschutzbeauftragter", "Geschäftsführer", "KI-Manager", "CISO"}
if len(rollen) != len(wantNamen) {
t.Fatalf("got %d Genehmiger-Rollen, want %d", len(rollen), len(wantNamen))
}
var namen []string
for _, r := range rollen {
namen = append(namen, r.Name)
}
for _, want := range wantNamen {
found := false
for _, n := range namen {
if n == want {
found = true
}
}
if !found {
t.Fatalf("Standard-Rolle %q wurde nicht angelegt, vorhanden: %v", want, namen)
}
}
}
func TestRegisterRejectsDuplicateEmail(t *testing.T) { func TestRegisterRejectsDuplicateEmail(t *testing.T) {
fs := newFakeStore() fs := newFakeStore()
s := newServer(t, fs) s := newServer(t, fs)

View File

@@ -1,9 +1,10 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens /* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt. Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width) ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
erweitert für größere Bildschirme. */ Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
@import url('/static/inter.css'); @import url('/static/inter.css');
@@ -14,6 +15,7 @@
--radius-lg: 10px; --radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); --shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--color-bg: #f8fafc; --color-bg: #f8fafc;
--color-text: #334155; --color-text: #334155;
@@ -27,6 +29,12 @@
--color-mittel-bg: #fffbeb; --color-mittel-bg: #fffbeb;
--color-niedrig: #166534; --color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4; --color-niedrig-bg: #f0fdf4;
--sidebar-width: 220px;
--sidebar-bg: linear-gradient(180deg, #0b1426 0%, #101d33 50%, #0d1829 100%);
--sidebar-text: #b6c2d9;
--sidebar-text-hover: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
} }
* { * {
@@ -51,7 +59,15 @@ h1, h2, h3 {
color: var(--color-heading); color: var(--color-heading);
font-weight: 600; font-weight: 600;
letter-spacing: -0.02em; letter-spacing: -0.02em;
margin: 0 0 0.5em; margin: 0 0 0.6em;
}
h2 {
font-size: 1.25rem;
}
h3 {
font-size: 1.0625rem;
} }
h1 { h1 {
@@ -74,23 +90,109 @@ a {
margin: 0 auto; margin: 0 auto;
} }
nav { /* ── Navigation ──────────────────────────────────────────────────────
Mobil: .topbar ist eine schmale, fixierte Kopfleiste mit einem
aufklappbaren <details>-Menü (siehe layout.html) — kein JavaScript,
das <details>-Element liefert Auf-/Zuklappen nativ. Ab Desktop-Breite
(siehe Media Query unten) wird dieselbe Markup-Struktur per CSS in
eine permanent sichtbare, dunkle Seitenleiste verwandelt. */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
gap: 16px;
padding: 12px 16px; padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--color-border);
} }
nav a { .brand {
color: var(--color-muted); font-weight: 600;
color: var(--color-heading);
text-decoration: none;
letter-spacing: -0.01em;
}
/* Checkbox-Hack: eine unsichtbare Checkbox plus ein <label for=...>
als Klick-/Tap-Ziel steuern per CSS-Geschwister-Selektor, ob .app-nav
sichtbar ist — funktioniert ohne JavaScript und ohne die Falle von
<details> (siehe Kommentar in layout.html). */
.nav-toggle {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.nav-toggle-label {
cursor: pointer;
padding: 8px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--color-text);
}
.nav-toggle:focus-visible ~ .nav-toggle-label {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
}
.nav-toggle:checked ~ .nav-toggle-label {
border-color: var(--branding-primary);
color: var(--branding-primary);
}
.app-nav {
display: none;
position: absolute;
top: calc(100% + 6px);
right: 16px;
z-index: 20;
flex-direction: column;
gap: 2px;
min-width: 220px;
padding: 8px;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.nav-toggle:checked ~ .app-nav {
display: flex;
}
.app-nav a {
padding: 10px 12px;
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none; text-decoration: none;
} }
nav a:hover { .app-nav a:hover {
color: var(--color-text); background: var(--color-bg);
color: var(--branding-primary);
}
.app-nav form {
margin: 4px 0 0;
}
.app-nav button {
width: 100%;
margin-top: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
} }
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf /* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
@@ -181,6 +283,38 @@ nav button {
margin-top: 24px; margin-top: 24px;
} }
/* Support-Login-Hinweis (siehe currentImpersonator in middleware.go) —
bewusst auffällig (nicht im normalen .hinweis-Grauton), damit niemand
vergisst, gerade im Kontext eines fremden Kontos zu handeln. */
.impersonation-banner {
background: var(--color-mittel-bg);
color: var(--color-mittel);
padding: 10px 16px;
font-size: 0.875rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--color-mittel);
}
.impersonation-banner form {
margin: 0;
}
.impersonation-banner button {
margin-top: 0;
min-height: 32px;
padding: 4px 12px;
font-size: 0.8125rem;
}
@media (min-width: 960px) {
.impersonation-banner {
margin-left: var(--sidebar-width);
}
}
.fehler { .fehler {
background: var(--color-hoch-bg); background: var(--color-hoch-bg);
color: var(--color-hoch); color: var(--color-hoch);
@@ -266,6 +400,16 @@ nav button {
color: var(--color-mittel); color: var(--color-mittel);
} }
.status-niedrig {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-hoch {
background: var(--color-hoch-bg);
color: var(--color-hoch);
}
.beitraege-liste { .beitraege-liste {
list-style: none; list-style: none;
margin: 0 0 16px; margin: 0 0 16px;
@@ -275,7 +419,12 @@ nav button {
gap: 8px; gap: 8px;
} }
.beitraege-liste li a { .beitraege-liste li a,
/* Einträge ohne umschließenden Link (Zeilen mit Badge/Aktions-Buttons
statt Navigation, z. B. Nutzer-/Abteilungsliste) bekommen dieselbe
Karte direkt auf dem <li> — :has() unterscheidet beide Fälle, damit
ein verlinkter Eintrag nicht doppelt (li UND a) gerahmt wird. */
.beitraege-liste > li:not(:has(> a)) {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@@ -288,6 +437,16 @@ nav button {
text-decoration: none; text-decoration: none;
} }
.beitraege-liste > li:not(:has(> a)) form {
margin: 0;
flex: 0 0 auto;
max-width: 100%;
}
.beitraege-liste > li:not(:has(> a)) button {
margin-top: 0;
}
.beteiligte { .beteiligte {
list-style: none; list-style: none;
margin: 0 0 16px; margin: 0 0 16px;
@@ -392,8 +551,8 @@ button.entfernen {
} }
.fragebogen legend { .fragebogen legend {
font-weight: 500; font-weight: 600;
font-size: 0.9375rem; font-size: 1rem;
padding: 0 4px; padding: 0 4px;
} }
@@ -405,6 +564,130 @@ button.entfernen {
margin: 4px 12px 4px 0; margin: 4px 12px 4px 0;
} }
/* ── Formular-Karten (Enterprise-Look, siehe design/enterprise.css
.ant-card/.ant-form-item) ─────────────────────────────────────────
Ersetzt "ein Formular = eine flache, ununterbrochene Spalte" durch
klar abgegrenzte Abschnitte mit Titel und ein Grid für kurze Felder
nebeneinander statt jedes Feld einzeln untereinander. */
.form-card {
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
margin-bottom: 24px;
overflow: hidden;
}
.form-card form {
margin-bottom: 0;
}
.form-section {
border: none;
margin: 0;
padding: 20px;
min-width: 0;
}
.form-section + .form-section {
border-top: 1px solid var(--color-border);
}
/* <legend> ignoriert das padding des umschließenden <fieldset> (es
positioniert sich an dessen Border-Box, nicht an der Padding-Box) —
ohne eigenes padding-left säße die Abschnittsüberschrift bündig am
Kartenrand, während der restliche Inhalt eingerückt ist. */
.form-section > legend {
padding: 0 0 16px 0;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-heading);
width: 100%;
}
.form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0 16px;
}
@media (min-width: 640px) {
.form-grid {
grid-template-columns: 1fr 1fr;
}
.form-grid .form-full {
grid-column: 1 / -1;
}
}
/* Checkbox-/Radio-Gruppen als Chips statt einer langen
Untereinander-Liste — v. a. bei den zwölf Zweck-Kategorien war das
vorher eine kaum überblickbare Spalte. */
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.chip-group label {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 0;
padding: 7px 14px;
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text);
background: var(--color-bg);
cursor: pointer;
}
.chip-group input[type="checkbox"],
.chip-group input[type="radio"] {
width: auto;
min-height: 0;
margin: 0;
}
.chip-group label:has(input:checked) {
border-color: var(--branding-primary);
background: rgba(22, 119, 255, 0.08);
color: var(--branding-primary);
}
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
background: var(--color-bg);
}
.form-actions form,
.form-actions button {
margin: 0;
}
.form-gefahrenzone {
border-top: 1px solid var(--color-border);
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.form-gefahrenzone form {
margin: 0;
}
.fragebogen fieldset input[type="radio"] { .fragebogen fieldset input[type="radio"] {
width: auto; width: auto;
min-height: 0; min-height: 0;
@@ -424,7 +707,276 @@ button.entfernen {
display: block; display: block;
} }
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren /* Tabellen (z. B. Register-Export-Ansicht) — bewusst horizontal
scrollbar in einem eigenen Wrapper statt schrumpfender Spalten: bei
neun Spalten wie im Register würde Schrumpfen nur unlesbaren Text
erzeugen, siehe .table-scroll in register_liste.html. */
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: var(--radius-md);
overflow: hidden;
font-size: 0.875rem;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
th {
background: var(--color-bg);
color: var(--color-heading);
font-weight: 600;
font-size: 0.8125rem;
}
tbody tr:hover {
background: var(--color-bg);
}
/* Ohne dieses Signal sieht eine breite, am Fensterrand abgeschnittene
Tabelle wie ein Rendering-Fehler aus statt wie "hier geht's mit
Scrollen weiter" — der Fade ist bewusst immer sichtbar (kein JS, das
den Scroll-Zustand prüfen könnte), auch am Ende der Tabelle. */
.table-scroll {
overflow-x: auto;
box-shadow: var(--shadow);
border-radius: var(--radius-md);
margin-bottom: 16px;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
}
/* Filter-/Paginierungsleiste für Listen-Seiten (Werkzeugkatalog,
Register, ...) — ein einziges GET-Formular ohne JavaScript, siehe
pagination.go. */
.filter-bar {
display: flex;
/* explizit setzen, nicht dem initial-value überlassen: .filter-bar
landet manchmal direkt auf einem <form>, und "form { flex-direction:
column }" (oben) hat dieselbe Spezifität für DIESE Property, wenn
.filter-bar sie nicht selbst deklariert — das Formular würde dann
senkrecht stapeln statt in einer Zeile. */
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.filter-bar input[type="search"] {
flex: 1 1 220px;
min-height: 40px;
}
.filter-bar select {
flex: 0 1 200px;
min-height: 40px;
}
.filter-bar button {
margin-top: 0;
min-height: 40px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.pagination button {
margin-top: 0;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
/* ── Responsive Tabellen — Card-Ansicht auf dem Handy ────────────────
enconf-Pattern (ProTable.tsx): auf schmalen Screens wird nicht
horizontal gescrollt, sondern jede Zeile zu einer eigenen Karte mit
Label/Wert-Paaren. Braucht data-label="Spaltenname" auf jedem <td>.
Ab 640px bleibt es eine normale Tabelle (mit Scroll-Fade als
Sicherheitsnetz bei vielen Spalten). */
.table-responsive {
overflow-x: auto;
box-shadow: var(--shadow);
border-radius: var(--radius-md);
margin-bottom: 16px;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
}
@media (max-width: 639px) {
.table-responsive {
overflow-x: visible;
box-shadow: none;
border-radius: 0;
-webkit-mask-image: none;
mask-image: none;
}
.table-responsive table,
.table-responsive tbody,
.table-responsive tr,
.table-responsive td {
display: block;
width: 100%;
}
.table-responsive thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.table-responsive tbody tr {
margin-bottom: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: #fff;
box-shadow: var(--shadow-sm);
}
.table-responsive td {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
text-align: right;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
white-space: normal;
}
.table-responsive td:last-child {
border-bottom: none;
}
.table-responsive td::before {
content: attr(data-label);
font-weight: 600;
color: var(--color-heading);
text-align: left;
flex-shrink: 0;
}
}
/* ── Seitenkopf (PageHeader-Pattern, enconf) ─────────────────────────
Titel + Unterzeile links, primäre Aktion rechts — statt eines
nackten <h1> mit einem Link irgendwo unten auf der Seite. */
.page-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.page-header h1 {
margin-bottom: 4px;
}
.page-header .hinweis {
margin-top: 0;
}
.page-header .page-header-action {
margin-top: 4px;
white-space: nowrap;
display: inline-block;
padding: 10px 18px;
border-radius: var(--radius);
background: var(--branding-primary);
color: #fff;
font-weight: 500;
font-size: 0.9375rem;
text-decoration: none;
box-shadow: var(--shadow-sm);
}
.page-header .page-header-action:hover {
filter: brightness(0.94);
}
/* ── Kennzahl-Kacheln (StatCard-Pattern, enconf Dashboard) ───────────
Vorher: einfache Text-Links in einer Liste ohne visuelle Priorität.
Jetzt: farbiger oberer Akzentrand + große Zahl + Klick navigiert zur
Liste, wie enconfs StatCard-Komponente — nur ohne Icon-Bibliothek. */
.stat-cards {
list-style: none;
margin: 0 0 24px;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat-card {
display: block;
background: #fff;
border: 1px solid var(--color-border);
border-top: 3px solid var(--branding-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: 16px 18px;
text-decoration: none;
transition: box-shadow 0.15s ease;
}
.stat-card:hover {
box-shadow: var(--shadow-md);
}
.stat-card-value {
display: block;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-heading);
line-height: 1.2;
}
.stat-card-label {
display: block;
font-size: 0.875rem;
color: var(--color-muted);
margin-top: 4px;
}
.stat-card-mittel {
border-top-color: var(--color-mittel);
}
.stat-card-hoch {
border-top-color: var(--color-hoch);
}
.stat-card-action {
background: var(--branding-primary);
border-color: var(--branding-primary);
display: flex;
align-items: center;
height: 100%;
}
.stat-card-action .stat-card-label {
color: #fff;
font-weight: 600;
font-size: 0.9375rem;
margin-top: 0;
}
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */ Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) { @media (min-width: 640px) {
.page { .page {
@@ -435,3 +987,87 @@ button.entfernen {
font-size: 1.75rem; font-size: 1.75rem;
} }
} }
/* Ab Desktop-Breite: dieselbe .topbar/.app-nav-Markup wird zu einer
permanent sichtbaren, dunklen Seitenleiste — kein separates
Desktop-Template, nur CSS. Der `details`/`summary`-Aufklapp-
Mechanismus wird hier überschrieben (Inhalt immer sichtbar,
summary versteckt), das Handy-Verhalten bleibt darunter unverändert. */
@media (min-width: 960px) {
.topbar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 8px;
padding: 20px 16px;
background: var(--sidebar-bg);
border-bottom: none;
border-right: 1px solid rgba(255, 255, 255, 0.08);
z-index: 10;
}
.brand {
color: #fff;
font-size: 1.125rem;
margin-bottom: 12px;
}
.nav-toggle-label {
display: none;
}
.app-nav {
display: flex;
position: static;
min-width: 0;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.app-nav a {
color: var(--sidebar-text);
}
.app-nav a:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
}
.app-nav button {
color: var(--sidebar-text);
border-color: rgba(255, 255, 255, 0.15);
}
.app-nav button:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
filter: none;
}
.page {
margin-left: var(--sidebar-width);
max-width: 1100px;
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> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<h1>Abteilungen</h1> <div class="page-header">
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p> <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 .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if not .Abteilungen}} {{if not .Abteilungen}}
<p class="hinweis">Noch keine Abteilungen.</p> <p class="hinweis">Noch keine Abteilungen.</p>
@@ -15,7 +19,7 @@
<li> <li>
{{.Name}} {{.Name}}
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')"> <form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
<button type="submit">Löschen</button> <button type="submit" class="entfernen">Löschen</button>
</form> </form>
</li> </li>
{{end}} {{end}}

View File

@@ -3,23 +3,43 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<h1>Meine Anträge</h1> <div class="page-header">
{{if not .Antraege}} <div>
<p class="hinweis">Noch keine Anträge gestellt.</p> <h1>Meine Anträge</h1>
{{else}} <p class="hinweis">Eigene Anträge und ihr Bearbeitungsstand.</p>
<ul class="beitraege-liste"> </div>
{{range .Antraege}} <a class="page-header-action" href="/antraege/neu">Neuen Antrag stellen</a>
<li> </div>
<a href="/antraege/{{.ID}}"> <form method="get">
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}} <div class="filter-bar">
<span class="status status-{{.Status}}">{{.Status}}</span> <input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Status, Häufigkeit)">
</a> <button type="submit">Filtern</button>
</li> {{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}} {{end}}
</ul> </form>
{{end}}
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<p><a href="/antraege">&larr; Meine Anträge</a></p> <p><a href="/antraege">&larr; Meine Anträge</a></p>
<h1>{{.Titel}}</h1> <h1>{{.Titel}}</h1>
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p> <p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
@@ -13,6 +13,15 @@
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p> <p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p> <p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Antworten}}
<h2>Antworten im Fragebogen</h2>
<ul class="findings">
{{range .Antworten}}
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
{{end}}
</ul>
{{end}}
{{if .Bewertung}} {{if .Bewertung}}
{{template "bewertung-block" .Bewertung}} {{template "bewertung-block" .Bewertung}}
{{else}} {{else}}

View File

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

View File

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

View File

@@ -3,11 +3,22 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<p><a href="/betreiber">&larr; Plattform</a></p> <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}} {{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}} {{else}}
<ul class="beitraege-liste"> <ul class="beitraege-liste">
{{range .Accounts}} {{range .Accounts}}
@@ -18,6 +29,25 @@
</li> </li>
{{end}} {{end}}
</ul> </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}} {{end}}
</div> </div>
</body> </body>

View File

@@ -3,11 +3,21 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<p><a href="/betreiber">&larr; Plattform</a></p> <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}} {{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}} {{else}}
<ul class="beteiligte"> <ul class="beteiligte">
{{range .Entries}} {{range .Entries}}
@@ -17,6 +27,25 @@
</li> </li>
{{end}} {{end}}
</ul> </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}} {{end}}
</div> </div>
</body> </body>

View File

@@ -3,12 +3,27 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<h1>Plattform</h1> <h1>Plattform</h1>
<ul class="admin-kacheln"> <ul class="stat-cards">
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li> <li>
<li><a href="/betreiber/werkzeuge">Werkzeugkatalog <span class="status">{{.WerkzeugCount}}</span></a></li> <a class="stat-card" href="/betreiber/accounts">
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li> <span class="stat-card-value">{{.AccountCount}}</span>
<span class="stat-card-label">Accounts</span>
</a>
</li>
<li>
<a class="stat-card" href="/betreiber/werkzeuge">
<span class="stat-card-value">{{.WerkzeugCount}}</span>
<span class="stat-card-label">Werkzeugkatalog</span>
</a>
</li>
<li>
<a class="stat-card" href="/betreiber/audit-log">
<span class="stat-card-value">{{.RecentAuditCount}}</span>
<span class="stat-card-label">Audit-Log (neueste)</span>
</a>
</li>
</ul> </ul>
</div> </div>
</body> </body>

View File

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

View File

@@ -3,23 +3,68 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<p><a href="/betreiber">&larr; Plattform</a></p> <p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Werkzeugkatalog</h1> <div class="page-header">
{{if not .Werkzeuge}} <div>
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p> <h1>Werkzeugkatalog</h1>
{{else}} <p class="hinweis">Zentral gepflegt, für alle Mandanten sichtbar.</p>
<ul class="beitraege-liste"> </div>
{{range .Werkzeuge}} <a class="page-header-action" href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a>
<li> </div>
<a href="/betreiber/werkzeuge/{{.ID}}"> <form method="get">
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}} <div class="filter-bar">
</a> <input type="search" name="q" value="{{.Filter.Suche}}" placeholder="Suche (Name, Anbieter, Land)">
</li> <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}} {{end}}
</ul> </form>
{{end}}
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,22 @@
{{define "einladung"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<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}}
<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>
</form>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,12 @@
{{define "einladung-ungueltig"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Einladung</h1>
<p class="fehler">Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen aktuellen Link anfragen.</p>
<p><a href="/login">Zur Anmeldung</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,20 @@
{{define "einladung-verwaltung"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Einladungslink</h1>
<p class="hinweis">
Ein Sammellink für alle Mitarbeitenden — jede Person, die den Link
öffnet, kann sich selbst mit eigenem Passwort anmelden (Rolle
"mitarbeiter"). Kein Ablaufdatum, aber jederzeit erneuerbar.
</p>
<p><input type="text" readonly value="{{.Link}}" onclick="this.select()"></p>
<form method="post" action="/verwaltung/einladung/erneuern" onsubmit="return confirm('Neuen Link erzeugen? Der alte Link wird dann sofort ungültig.')">
<button type="submit" class="entfernen">Link erneuern</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -3,21 +3,41 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<h1>Posteingang</h1> <div class="page-header">
{{if not .Faelle}} <div>
<p class="keine-findings">Keine offenen Anträge — alles bearbeitet.</p> <h1>Posteingang</h1>
{{else}} <p class="hinweis">Alle noch offenen Anträge deines Mandanten.</p>
<ul class="beitraege-liste"> </div>
{{range .Faelle}} </div>
<li> <form method="get">
<a href="/faelle/{{.ID}}"> <div class="filter-bar">
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · eingereicht am {{.CreatedAt}} <input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Häufigkeit)">
</a> <button type="submit">Filtern</button>
</li> {{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}} {{end}}
</ul> </form>
{{end}}
</div> </div>
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<p><a href="/faelle">&larr; Posteingang</a></p> <p><a href="/faelle">&larr; Posteingang</a></p>
<h1>{{.Titel}}</h1> <h1>{{.Titel}}</h1>
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p> <p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
@@ -13,6 +13,15 @@
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p> <p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p> <p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Antworten}}
<h2>Antworten im Fragebogen</h2>
<ul class="findings">
{{range .Antworten}}
<li class="finding finding-niedrig"><strong>{{.Frage}}</strong><p>{{.Antwort}}</p></li>
{{end}}
</ul>
{{end}}
{{if .Bewertung}} {{if .Bewertung}}
{{template "bewertung-block" .Bewertung}} {{template "bewertung-block" .Bewertung}}
{{else}} {{else}}
@@ -24,6 +33,23 @@
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br> <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> <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 .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}} {{else if .CanEntscheiden}}
<h2>Entscheiden</h2> <h2>Entscheiden</h2>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}} {{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}

View File

@@ -0,0 +1,93 @@
{{define "freigabe-regeln-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<div class="page-header">
<div>
<h1>Freigabe-Regeln</h1>
<p class="hinweis">
"Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
Genehmiger-Rolle Y nötig." Bedingungen sind die vom Regelwerk
abgeleiteten Anforderungen/Einstufungen/Datenklassen — keine
freie Regel, sondern eine Auswahl aus dem, was das System ohnehin
schon berechnet. Eine Genehmigung durch die Fachebene wartet dann
erst auf diese zusätzlichen Freigaben, bevor sie endgültig ist;
lehnt eine davon ab, gilt der gesamte Antrag als abgelehnt.
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if not .RollenOptionen}}
<p class="hinweis">
Zuerst mindestens eine <a href="/verwaltung/genehmiger-rollen">Genehmiger-Rolle</a>
anlegen, bevor eine Freigabe-Regel definiert werden kann.
</p>
{{else}}
<div class="form-card">
<form method="post" action="/verwaltung/freigabe-regeln">
<fieldset class="form-section">
<legend>Neue Freigabe-Regel</legend>
<div class="form-grid">
<div>
<label for="bedingung">Bedingung</label>
<select id="bedingung" name="bedingung" required>
<option value="">Bitte wählen…</option>
{{range .BedingungGruppen}}
{{$gruppe := .Label}}
<optgroup label="{{$gruppe}}">
{{range .Optionen}}
<option value="{{.Value}}">{{.Label}}</option>
{{end}}
</optgroup>
{{end}}
</select>
</div>
<div>
<label for="genehmiger_rolle_id">Genehmiger-Rolle</label>
<select id="genehmiger_rolle_id" name="genehmiger_rolle_id" required>
<option value="">Bitte wählen…</option>
{{range .RollenOptionen}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>
</form>
</div>
{{end}}
{{if not .Regeln}}
<p class="hinweis">Noch keine Freigabe-Regeln.</p>
{{else}}
<div class="table-responsive">
<table>
<thead>
<tr><th>Bedingung</th><th>Erforderliche Freigabe</th><th></th></tr>
</thead>
<tbody>
{{range .Regeln}}
<tr>
<td data-label="Bedingung">{{.BedingungLabel}}</td>
<td data-label="Erforderliche Freigabe">{{.GenehmigerRolle}}</td>
<td data-label="">
<form method="post" action="/verwaltung/freigabe-regeln/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Freigabe-Regel wirklich löschen?')">
<button type="submit" class="entfernen">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,38 @@
{{define "freigaben-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<div class="page-header">
<div>
<h1>Meine Freigaben</h1>
<p class="hinweis">Anträge, die zusätzlich zur Fachebene-Entscheidung deine Freigabe brauchen.</p>
</div>
</div>
{{if not .Freigaben}}
<p class="keine-findings">Keine ausstehenden Freigaben.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Freigaben}}
<li>
<div class="beteiligter-kopf">
<strong>{{.AntragTitel}}</strong> · Freigabe als {{.RolleName}} · seit {{.CreatedAt}}
</div>
<form method="post" action="/freigaben/{{.ID}}/entscheiden">
<label for="kommentar-{{.ID}}">Kommentar (optional, Pflicht bei Ablehnung empfohlen)</label>
<input type="text" id="kommentar-{{.ID}}" name="kommentar">
<div class="form-actions">
<button type="submit" name="entscheidung" value="genehmigt">Freigeben</button>
<button type="submit" name="entscheidung" value="abgelehnt" class="entfernen" onclick="return confirm('Wirklich ablehnen? Das lehnt den gesamten Antrag ab.')">Ablehnen</button>
</div>
</form>
<p><a href="/faelle/{{.AntragID}}">Antrag ansehen →</a></p>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,83 @@
{{define "genehmiger-rollen-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<div class="page-header">
<div>
<h1>Genehmiger-Rollen</h1>
<p class="hinweis">
Freigabe-Funktionen wie "Datenschutzbeauftragter" oder
"Geschäftsführung" — unabhängig von den normalen Zugriffsrollen,
eine Person kann beides zugleich sein. Wer wann zusätzlich
freigeben muss, legst du unter
<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a> fest.
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/genehmiger-rollen">
<fieldset class="form-section">
<legend>Neue Genehmiger-Rolle</legend>
<div class="form-grid">
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required>
</div>
<div>
<label for="beschreibung">Beschreibung (optional)</label>
<input type="text" id="beschreibung" name="beschreibung" placeholder="z. B. Prüft den Antrag aus Sicherheitssicht">
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>
</form>
</div>
{{if not .Rollen}}
<p class="hinweis">Noch keine Genehmiger-Rollen.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Rollen}}
{{$rolleID := .ID}}
<li>
<div class="beteiligter-kopf"><strong>{{.Name}}</strong>{{if .Beschreibung}} — {{.Beschreibung}}{{end}}</div>
{{if .Mitglieder}}
<ul class="beitraege-liste">
{{range .Mitglieder}}
<li>
{{.Email}}
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder/{{.ID}}/entfernen" style="display:inline">
<button type="submit" class="entfernen">Entfernen</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="hinweis">Noch keine Mitglieder.</p>
{{end}}
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder" style="display:inline">
<select name="user_id" required>
<option value="">Person hinzufügen…</option>
{{range $.Nutzer}}
<option value="{{.ID}}">{{.Email}}</option>
{{end}}
</select>
<button type="submit">Hinzufügen</button>
</form>
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/loeschen" style="display:inline" onsubmit="return confirm('Genehmiger-Rolle wirklich löschen?')">
<button type="submit" class="entfernen">Rolle löschen</button>
</form>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

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

View File

@@ -6,21 +6,49 @@
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
{{end}} {{end}}
{{/* Eine einzige Nav-Definition für alle Seiten (siehe navData in
middleware.go) — auf dem Handy ein aufklappbares Menü (Checkbox-
Hack, kein JavaScript: das <details>-Element scheitert daran, dass
aktuelle Chromium-Versionen dessen Auf-/Zuklapp-Zustand über eine
interne, nicht per CSS überschreibbare Animation steuern), ab
Desktop-Breite per CSS in eine permanent sichtbare Seitenleiste
verwandelt. Dieselbe Markup-Struktur bedient beide Layouts, siehe
app.css .app-nav. Die Checkbox ist bei jedem Seitenaufruf frisch
unchecked (serverseitig gerendert, kein Zustand über Navigation
hinweg nötig). */}}
{{define "nav"}} {{define "nav"}}
<nav> <header class="topbar">
<a href="/">Start</a> <a class="brand" href="/">Deklarix</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}} <label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen">Menü</label>
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}} <nav class="app-nav">
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}} <a href="/">Start</a>
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}} {{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}} {{if not .IsBetreiber}}<a href="/freigaben">Meine Freigaben</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}} {{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}} {{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</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 .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout">
<button type="submit">Abmelden</button>
</form>
</nav>
</header>
{{if .Impersonator}}
<div class="impersonation-banner">
Support-Zugriff durch <strong>{{.Impersonator}}</strong> — dies ist nicht Ihre eigene Sitzung.
<form method="post" action="/logout" style="display:inline"> <form method="post" action="/logout" style="display:inline">
<button type="submit">Abmelden</button> <button type="submit">Sitzung beenden</button>
</form> </form>
</nav> </div>
{{end}}
{{end}} {{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO- {{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-

View File

@@ -0,0 +1,43 @@
{{define "loeschfristen-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page-header">
<div>
<h1>Löschfristen</h1>
<p class="hinweis">
Maximale Aufbewahrungsdauer je Datenklasse, gegen die ein Werkzeug
hart geprüft wird (Anforderung <code>loeschfrist_max_tage</code>).
Die DSGVO nennt keine festen Fristen, nur den Grundsatz "so lange
wie für den Zweck nötig" — die Werte unten sind ein Vorschlag,
keine Rechtsvorgabe, und sollten vom Datenschutzbeauftragten
geprüft und bei Bedarf angepasst werden.
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Gespeichert}}<p class="hinweis">Löschfristen gespeichert.</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/loeschfristen">
<fieldset class="form-section">
<legend>Maximale Aufbewahrungsdauer (Tage)</legend>
<div class="form-grid">
{{range .Zeilen}}
<div>
<label for="max_tage_{{.DatenklasseID}}">{{.DatenklasseID}}</label>
<input type="number" min="0" id="max_tage_{{.DatenklasseID}}" name="max_tage_{{.DatenklasseID}}" value="{{.MaxTage}}" required>
</div>
{{end}}
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -5,6 +5,7 @@
<div class="page"> <div class="page">
<h1>Anmelden</h1> <h1>Anmelden</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}} {{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
<form method="post" action="/login"> <form method="post" action="/login">
<label for="email">E-Mail</label> <label for="email">E-Mail</label>
<input type="email" id="email" name="email" required> <input type="email" id="email" name="email" required>
@@ -15,6 +16,7 @@
<button type="submit">Anmelden</button> <button type="submit">Anmelden</button>
</form> </form>
<p><a href="/register">Noch kein Konto? Registrieren</a></p> <p><a href="/register">Noch kein Konto? Registrieren</a></p>
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -3,34 +3,124 @@
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .Nav}} {{template "nav" .Nav}}
<div class="page"> <div class="page page-wide">
<h1>Werkzeugkatalog</h1> <h1>Werkzeugkatalog</h1>
<p class="hinweis">
Der zentrale Katalog wird von der Plattform gepflegt. Ein gesperrtes <div class="page-header">
Werkzeug bleibt im zentralen Katalog unverändert — es wird nur für <div>
diesen Mandanten aus der Werkzeugsuche ausgeblendet. <h2>Zentraler Katalog</h2>
</p> <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}} {{if not .Werkzeuge}}
<p class="hinweis">Der zentrale Katalog ist noch leer.</p> <p class="hinweis">{{if .Filter.Aktiv}}Keine Treffer für diese Filter.{{else}}Der zentrale Katalog ist noch leer.{{end}}</p>
{{else}} {{else}}
<ul class="beitraege-liste"> <div class="table-responsive">
{{range .Werkzeuge}} <table>
<li> <thead>
{{.Name}} · {{.Anbieter}} <tr>
{{if .Gesperrt}} <th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>AVV</th>
<span class="status">gesperrt</span> <th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th>
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline"> </tr>
<button type="submit">Entsperren</button> </thead>
</form> <tbody>
{{else}} {{range .Werkzeuge}}
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline"> <tr>
<input type="text" name="grund" placeholder="Grund (optional)"> <td data-label="Name">{{.Name}}</td>
<button type="submit">Sperren</button> <td data-label="Anbieter">{{.Anbieter}}</td>
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
<td data-label="AVV">{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td data-label="Training aus">{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td data-label="Aufbewahrung">{{.Aufbewahrung}}</td>
<td data-label="Geeignete Zwecke">{{.GeeigneteZwecke}}</td>
<td data-label="Zuletzt geprüft">{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
<td data-label="Status">
{{if .Gesperrt}}
<span class="status">gesperrt</span>
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
<button type="submit">Entsperren</button>
</form>
{{else}}
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
<input type="text" name="grund" placeholder="Grund (optional)">
<button type="submit">Sperren</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="pagination">
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
<span>
{{if .Pagination.HasPrev}}
<form method="get" style="display:inline">
<input type="hidden" name="q" value="{{.Filter.Suche}}">
<input type="hidden" name="zweck" value="{{.Filter.Zweck}}">
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
<button type="submit">&larr; Zurück</button>
</form> </form>
{{end}} {{end}}
</li> {{if .Pagination.HasNext}}
{{end}} <form method="get" style="display:inline">
</ul> <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}}
<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-responsive">
<table>
<thead>
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsländer</th><th>Zuletzt geprüft</th></tr>
</thead>
<tbody>
{{range .EigeneWerkzeuge}}
<tr>
<td data-label="Name"><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td data-label="Anbieter">{{.Anbieter}}</td>
<td data-label="Verarbeitungsländer">{{.Verarbeitungslaender}}</td>
<td data-label="Zuletzt geprüft">{{.LetztePruefung}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}} {{end}}
</div> </div>
</body> </body>

View File

@@ -0,0 +1,27 @@
{{define "nutzer-import"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/verwaltung/nutzer">&larr; Nutzerverwaltung</a></p>
<h1>Mitarbeiter per CSV importieren</h1>
<p class="hinweis">
CSV mit Kopfzeile, Pflichtspalte "email", optionale Spalte "role"
(mitarbeiter/verantwortlicher/pruefer/admin — Standard: mitarbeiter).
Es gibt keine Einladungsmail: nach dem Import wird pro Zeile ein
Einmalpasswort angezeigt, das nur DIESES EINE MAL sichtbar ist — bitte
auf dieser Seite kopieren und sicher weitergeben, bevor sie
geschlossen wird.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
<button type="submit">Importieren</button>
</form>
</div>
</body>
</html>
{{end}}

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