diff --git a/CLAUDE.md b/CLAUDE.md index b8c8406..6e93363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -478,6 +478,118 @@ 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 `` 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 +` 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) +} diff --git a/internal/web/freigabe_handlers_test.go b/internal/web/freigabe_handlers_test.go new file mode 100644 index 0000000..82b34bd --- /dev/null +++ b/internal/web/freigabe_handlers_test.go @@ -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()) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 700c7bd..64f9683 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -76,6 +76,27 @@ type Store interface { GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) + + CreateGenehmigerRolle(ctx context.Context, accountID, name 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 @@ -151,6 +172,16 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm)) mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate)) mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete)) + mux.HandleFunc("GET /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRollenListe)) + mux.HandleFunc("POST /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRolleCreate)) + mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/loeschen", s.requireAdmin(s.handleGenehmigerRolleDelete)) + mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder", s.requireAdmin(s.handleGenehmigerRolleMitgliedHinzufuegen)) + mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder/{userID}/entfernen", s.requireAdmin(s.handleGenehmigerRolleMitgliedEntfernen)) + mux.HandleFunc("GET /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelnListe)) + mux.HandleFunc("POST /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelCreate)) + mux.HandleFunc("POST /verwaltung/freigabe-regeln/{id}/loeschen", s.requireAdmin(s.handleFreigabeRegelDelete)) + mux.HandleFunc("GET /freigaben", s.requirePage(s.handleFreigabenListe)) + mux.HandleFunc("POST /freigaben/{id}/entscheiden", s.requireAPI(s.handleFreigabeEntscheiden)) mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 0570d89..b38a343 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -38,21 +38,30 @@ type fakeStore struct { entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen + + genehmigerRollen map[string]store.GenehmigerRolle + nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID + freigabeRegeln map[string]store.FreigabeRegel + freigabeschritte map[string]store.Freigabeschritt } func newFakeStore() *fakeStore { return &fakeStore{ - accounts: map[string]store.Account{}, - users: map[string]store.User{}, - usersByEmail: map[string]string{}, - sessions: map[string]store.Session{}, - abteilungen: map[string][]store.Abteilung{}, - antraege: map[string]store.Antrag{}, - werkzeuge: map[string]store.Werkzeug{}, - bewertungen: map[string][]store.Bewertung{}, - entscheidungen: map[string][]store.Entscheidung{}, - registereintraege: map[string][]store.Registereintrag{}, - werkzeugSperren: map[string][]store.WerkzeugSperre{}, + accounts: map[string]store.Account{}, + users: map[string]store.User{}, + usersByEmail: map[string]string{}, + sessions: map[string]store.Session{}, + abteilungen: map[string][]store.Abteilung{}, + antraege: map[string]store.Antrag{}, + werkzeuge: map[string]store.Werkzeug{}, + bewertungen: map[string][]store.Bewertung{}, + entscheidungen: map[string][]store.Entscheidung{}, + registereintraege: map[string][]store.Registereintrag{}, + werkzeugSperren: map[string][]store.WerkzeugSperre{}, + genehmigerRollen: map[string]store.GenehmigerRolle{}, + nutzerGenehmigerRollen: map[string]map[string]bool{}, + freigabeRegeln: map[string]store.FreigabeRegel{}, + freigabeschritte: map[string]store.Freigabeschritt{}, } } @@ -547,6 +556,208 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account return f.registereintraege[accountID], nil } +func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error) { + f.mu.Lock() + defer f.mu.Unlock() + g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()} + f.genehmigerRollen[g.ID] = g + return g, nil +} + +func (f *fakeStore) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) { + f.mu.Lock() + defer f.mu.Unlock() + g, ok := f.genehmigerRollen[id] + if !ok { + return store.GenehmigerRolle{}, store.ErrNotFound + } + return g, nil +} + +func (f *fakeStore) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.GenehmigerRolle + for _, g := range f.genehmigerRollen { + if g.AccountID == accountID { + out = append(out, g) + } + } + return out, nil +} + +func (f *fakeStore) DeleteGenehmigerRolle(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.genehmigerRollen[id]; !ok { + return store.ErrNotFound + } + delete(f.genehmigerRollen, id) + return nil +} + +func (f *fakeStore) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.nutzerGenehmigerRollen[userID] == nil { + f.nutzerGenehmigerRollen[userID] = map[string]bool{} + } + f.nutzerGenehmigerRollen[userID][genehmigerRolleID] = true + return nil +} + +func (f *fakeStore) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.nutzerGenehmigerRollen[userID], genehmigerRolleID) + return nil +} + +func (f *fakeStore) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.GenehmigerRolle + for grID := range f.nutzerGenehmigerRollen[userID] { + if g, ok := f.genehmigerRollen[grID]; ok { + out = append(out, g) + } + } + return out, nil +} + +func (f *fakeStore) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.GenehmigerMitglied + for userID, set := range f.nutzerGenehmigerRollen { + if !set[genehmigerRolleID] { + continue + } + if u, ok := f.users[userID]; ok { + out = append(out, store.GenehmigerMitglied{UserID: u.ID, Email: u.Email}) + } + } + return out, nil +} + +func (f *fakeStore) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error) { + f.mu.Lock() + defer f.mu.Unlock() + fr := store.FreigabeRegel{ + ID: f.newID(), AccountID: accountID, BedingungTyp: bedingungTyp, + BedingungWert: bedingungWert, GenehmigerRolleID: genehmigerRolleID, CreatedAt: time.Now(), + } + f.freigabeRegeln[fr.ID] = fr + return fr, nil +} + +func (f *fakeStore) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.FreigabeRegel + for _, fr := range f.freigabeRegeln { + if fr.AccountID == accountID { + out = append(out, fr) + } + } + return out, nil +} + +func (f *fakeStore) GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error) { + f.mu.Lock() + defer f.mu.Unlock() + fr, ok := f.freigabeRegeln[id] + if !ok { + return store.FreigabeRegel{}, store.ErrNotFound + } + return fr, nil +} + +func (f *fakeStore) DeleteFreigabeRegel(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.freigabeRegeln[id]; !ok { + return store.ErrNotFound + } + delete(f.freigabeRegeln, id) + return nil +} + +func (f *fakeStore) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error) { + f.mu.Lock() + defer f.mu.Unlock() + fs := store.Freigabeschritt{ + ID: f.newID(), AntragID: antragID, GenehmigerRolleID: genehmigerRolleID, + Status: "ausstehend", CreatedAt: time.Now(), + } + f.freigabeschritte[fs.ID] = fs + return fs, nil +} + +func (f *fakeStore) GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error) { + f.mu.Lock() + defer f.mu.Unlock() + fs, ok := f.freigabeschritte[id] + if !ok { + return store.Freigabeschritt{}, store.ErrNotFound + } + return fs, nil +} + +func (f *fakeStore) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Freigabeschritt + for _, fs := range f.freigabeschritte { + if fs.AntragID == antragID { + out = append(out, fs) + } + } + return out, nil +} + +func (f *fakeStore) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Freigabeschritt + for grID := range f.nutzerGenehmigerRollen[userID] { + for _, fs := range f.freigabeschritte { + if fs.GenehmigerRolleID == grID && fs.Status == "ausstehend" { + out = append(out, fs) + } + } + } + return out, nil +} + +func (f *fakeStore) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error) { + f.mu.Lock() + defer f.mu.Unlock() + fs, ok := f.freigabeschritte[id] + if !ok || fs.Status != "ausstehend" { + return store.Freigabeschritt{}, store.ErrNotFound + } + now := time.Now() + fs.Status, fs.EntschiedenVon, fs.EntschiedenAm, fs.Kommentar = status, &entschiedenVon, &now, kommentar + f.freigabeschritte[id] = fs + return fs, nil +} + +func (f *fakeStore) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error { + f.mu.Lock() + defer f.mu.Unlock() + for id, fs := range f.freigabeschritte { + if fs.AntragID == antragID && fs.Status == "ausstehend" && id != ausloesenderSchrittID { + fs.Status = "abgelehnt" + now := time.Now() + fs.EntschiedenAm = &now + fs.Kommentar = "Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde." + f.freigabeschritte[id] = fs + } + } + return nil +} + // ─── Test-Setup ─────────────────────────────────────────────────────── // loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben, diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 31fe75d..056374b 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -440,6 +440,7 @@ nav button { .beitraege-liste > li:not(:has(> a)) form { margin: 0; flex: 0 0 auto; + max-width: 100%; } .beitraege-liste > li:not(:has(> a)) button { diff --git a/internal/web/templates/fall_detail.html b/internal/web/templates/fall_detail.html index bfbf464..41c712d 100644 --- a/internal/web/templates/fall_detail.html +++ b/internal/web/templates/fall_detail.html @@ -33,6 +33,23 @@

{{.Entscheidung.Entscheidung}}{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}
am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}

{{if .Entscheidung.Begruendung}}

{{.Entscheidung.Begruendung}}

{{end}} + +{{if .Freigabeschritte}} +

Zusätzliche Freigaben

+{{if eq .Status "wartet_auf_freigabe"}} +

Die Genehmigung ist erst endgültig, wenn alle unten stehenden Freigaben erteilt sind.

+{{end}} + +{{end}} + {{else if .CanEntscheiden}}

Entscheiden

{{if .Error}}

{{.Error}}

{{end}} diff --git a/internal/web/templates/freigabe_regeln_liste.html b/internal/web/templates/freigabe_regeln_liste.html new file mode 100644 index 0000000..594e7f3 --- /dev/null +++ b/internal/web/templates/freigabe_regeln_liste.html @@ -0,0 +1,93 @@ +{{define "freigabe-regeln-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+ +{{if .Error}}

{{.Error}}

{{end}} + +{{if not .RollenOptionen}} +

+ Zuerst mindestens eine Genehmiger-Rolle + anlegen, bevor eine Freigabe-Regel definiert werden kann. +

+{{else}} +
+
+
+ Neue Freigabe-Regel +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+{{end}} + +{{if not .Regeln}} +

Noch keine Freigabe-Regeln.

+{{else}} +
+ + + + + + {{range .Regeln}} + + + + + + {{end}} + +
BedingungErforderliche Freigabe
{{.BedingungLabel}}{{.GenehmigerRolle}} +
+ +
+
+
+{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/freigaben_liste.html b/internal/web/templates/freigaben_liste.html new file mode 100644 index 0000000..2f8f54c --- /dev/null +++ b/internal/web/templates/freigaben_liste.html @@ -0,0 +1,38 @@ +{{define "freigaben-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+ +{{if not .Freigaben}} +

Keine ausstehenden Freigaben.

+{{else}} + +{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/genehmiger_rollen_liste.html b/internal/web/templates/genehmiger_rollen_liste.html new file mode 100644 index 0000000..ab28927 --- /dev/null +++ b/internal/web/templates/genehmiger_rollen_liste.html @@ -0,0 +1,79 @@ +{{define "genehmiger-rollen-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+ +{{if .Error}}

{{.Error}}

{{end}} + +
+
+
+ Neue Genehmiger-Rolle +
+
+ + +
+
+
+
+ +
+
+
+ +{{if not .Rollen}} +

Noch keine Genehmiger-Rollen.

+{{else}} + +{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 53a599e..f090608 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -24,6 +24,7 @@