diff --git a/CLAUDE.md b/CLAUDE.md index 1d86fa3..486ad9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -625,7 +625,15 @@ journalctl -u deklarix -f hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder zwei `app_user`-Zeilen für dieselbe Person? -- **Zentraler Werkzeugkatalog ist leer** — "Werkzeugkatalog aus der - Plattform vorbelegt" beim Onboarding ist nicht automatisiert, es gibt - noch keine Ebene-5/Betreiber-UI zur Katalogpflege. +- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die + Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET + /betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu` + anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST + .../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`. + Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein + mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar + [404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma- + Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist + inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge + pflegt. - Markenanmeldung Deklarix, sobald erster Kunde zahlt. diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go index a9a9c5c..8ee4715 100644 --- a/internal/web/betreiber_handlers.go +++ b/internal/web/betreiber_handlers.go @@ -8,6 +8,7 @@ type betreiberDashboardData struct { Title string Nav navData AccountCount int + WerkzeugCount int RecentAuditCount int } @@ -21,6 +22,11 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) return } + werkzeuge, err := s.store.ListZentraleWerkzeuge(ctx) + if err != nil { + http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } auditLog, err := s.store.ListAuditLog(ctx, 5) if err != nil { http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) @@ -28,7 +34,8 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request } data := betreiberDashboardData{ - Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog), + Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), + WerkzeugCount: len(werkzeuge), RecentAuditCount: len(auditLog), } if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) diff --git a/internal/web/betreiber_werkzeug_handlers.go b/internal/web/betreiber_werkzeug_handlers.go new file mode 100644 index 0000000..063b8ee --- /dev/null +++ b/internal/web/betreiber_werkzeug_handlers.go @@ -0,0 +1,250 @@ +// Ebene 5 (Plattform) — Pflege des zentralen Werkzeugkatalogs +// (`werkzeug.account_id IS NULL`). Mandantenspezifische Ergänzungen +// (`account_id` gesetzt) sind NICHT Teil dieser Seiten — die bleiben +// Sache des jeweiligen Mandanten (Ebene 4, "eigene Werkzeug- +// Freigaben/-Sperrungen", noch nicht gebaut), siehe CLAUDE.md. +package web + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"} + +func istGueltigerVerarbeitungsort(v string) bool { + for _, o := range gueltigeVerarbeitungsorte { + if o == v { + return true + } + } + return false +} + +// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen, +// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne +// führende/folgende Leerzeichen und ohne leere Einträge. +func splitListe(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +type werkzeugListItem struct { + ID string + Name string + Anbieter string + Verarbeitungsort string + LetztePruefung string +} + +type werkzeugListeData struct { + Title string + Nav navData + Werkzeuge []werkzeugListItem +} + +// handleBetreiberWerkzeugListe zeigt den zentralen Katalog. +func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) { + werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context()) + if err != nil { + http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)} + for _, wz := range werkzeuge { + data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{ + ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, + LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), + }) + } + if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type werkzeugFormData struct { + Title string + Nav navData + ID string // leer bei "neu" + Name string + Anbieter string + Verarbeitungsorte []string + Verarbeitungsort string + AVVVerfuegbar bool + AVVURL string + TrainingOptOut bool + TrainingStandard bool + AufbewahrungTage int + Zertifizierungen string + GeeigneteZwecke string + Einschraenkungen string + LetztePruefung string + Quelle string + Error string +} + +func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) { + data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), Verarbeitungsorte: gueltigeVerarbeitungsorte} + if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular. +// AccountID ist immer nil — diese Seiten pflegen ausschließlich den +// zentralen Katalog. +func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) { + letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung")) + if err != nil { + return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt" + } + verarbeitungsort := r.FormValue("verarbeitungsort") + if !istGueltigerVerarbeitungsort(verarbeitungsort) { + return store.WerkzeugInput{}, "ungültiger Verarbeitungsort" + } + name := r.FormValue("name") + anbieter := r.FormValue("anbieter") + quelle := r.FormValue("quelle") + if name == "" || anbieter == "" || quelle == "" { + return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht" + } + aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage")) + + return store.WerkzeugInput{ + AccountID: nil, + Name: name, + Anbieter: anbieter, + Verarbeitungsort: verarbeitungsort, + AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", + AVVURL: r.FormValue("avv_url"), + TrainingOptOut: r.FormValue("training_opt_out") == "on", + TrainingStandard: r.FormValue("training_standard") == "on", + AufbewahrungTage: aufbewahrungTage, + Zertifizierungen: splitListe(r.FormValue("zertifizierungen")), + GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")), + Einschraenkungen: splitListe(r.FormValue("einschraenkungen")), + LetztePruefung: letztePruefung, + Quelle: quelle, + }, "" +} + +// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei +// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten, +// statt das Formular zu leeren. +func werkzeugFormDataFromRequest(r *http.Request, title, id, errMsg string) werkzeugFormData { + return werkzeugFormData{ + Title: title, Nav: navFor(r), ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte, + Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"), + AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"), + TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on", + Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"), + Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"), + Quelle: r.FormValue("quelle"), Error: errMsg, + } +} + +// handleBetreiberWerkzeugCreate legt einen zentralen Katalogeintrag an. +func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + in, errMsg := werkzeugInputFromForm(r) + if errMsg != "" { + data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", 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, "Werkzeug anlegen", "", "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, "/betreiber/werkzeuge", http.StatusSeeOther) +} + +// handleBetreiberWerkzeugEditForm zeigt einen zentralen Katalogeintrag +// zum Bearbeiten. Ein mandantenspezifischer Eintrag (account_id +// gesetzt) ist hier bewusst nicht erreichbar — das ist Sache des +// jeweiligen Mandanten, nicht der Plattform. +func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.Request) { + wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id")) + if err != nil || wz.AccountID != nil { + http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) + return + } + data := werkzeugFormData{ + 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 { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleBetreiberWerkzeugUpdate speichert Änderungen an einem zentralen +// Katalogeintrag. Werkzeug ist nicht append-only (siehe store.UpdateWerkzeug) +// — eine bereits getroffene Entscheidung hält ihren eigenen, zum +// Entscheidungszeitpunkt eingefrorenen Snapshot (entscheidung.werkzeug_snapshot). +func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + wz, err := s.store.GetWerkzeug(r.Context(), id) + if err != nil || wz.AccountID != nil { + 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) + if errMsg != "" { + data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", 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, "Werkzeug bearbeiten", 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, "/betreiber/werkzeuge", http.StatusSeeOther) +} + +// handleBetreiberWerkzeugDelete entfernt einen zentralen Katalogeintrag. +func (s *Server) handleBetreiberWerkzeugDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + wz, err := s.store.GetWerkzeug(r.Context(), id) + if err != nil || wz.AccountID != nil { + 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, "/betreiber/werkzeuge", http.StatusSeeOther) +} diff --git a/internal/web/betreiber_werkzeug_handlers_test.go b/internal/web/betreiber_werkzeug_handlers_test.go new file mode 100644 index 0000000..cf77d34 --- /dev/null +++ b/internal/web/betreiber_werkzeug_handlers_test.go @@ -0,0 +1,118 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/netcell-it/deklarix/internal/store" +) + +func werkzeugForm() url.Values { + return url.Values{ + "name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"}, + "avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"}, + "training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"}, + "zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"}, + "einschraenkungen": {""}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"}, + } +} + +func TestBetreiberCanCreateZentralesWerkzeug(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/werkzeuge", werkzeugForm()) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) + } + + var w store.Werkzeug + for _, wz := range fs.werkzeuge { + w = wz + } + if w.Name != "ChatGPT Enterprise" || w.AccountID != nil { + t.Fatalf("Werkzeug = %+v, unerwartete Werte", w) + } + if len(w.Zertifizierungen) != 2 { + t.Errorf("Zertifizierungen = %v, want 2 Einträge", w.Zertifizierungen) + } + + list := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge") + if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") { + t.Errorf("expected the new werkzeug in the Katalogliste, got: %s", list.Body.String()) + } +} + +func TestBetreiberWerkzeugCreateRequiresLetztePruefungUndQuelle(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + + form := werkzeugForm() + form.Set("letzte_pruefung", "") + resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", form) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (Formular mit Fehler) ohne letzte_pruefung", resp.Code) + } + if len(fs.werkzeuge) != 0 { + t.Errorf("expected no werkzeug to be created, got %d", len(fs.werkzeuge)) + } +} + +func TestBetreiberCanUpdateAndDeleteZentralesWerkzeug(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm()) + var id string + for wid := range fs.werkzeuge { + id = wid + } + + editResp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id) + if editResp.Code != http.StatusOK || !strings.Contains(editResp.Body.String(), "ChatGPT Enterprise") { + t.Fatalf("edit form status = %d, body: %s", editResp.Code, editResp.Body.String()) + } + + updateForm := werkzeugForm() + updateForm.Set("name", "ChatGPT Enterprise (aktualisiert)") + updateResp := postForm(t, s, betreiberCookie, "/betreiber/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 (aktualisiert)" { + t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name) + } + + deleteResp := postForm(t, s, betreiberCookie, "/betreiber/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 werkzeug to be deleted") + } +} + +// TestBetreiberCannotEditMandantenEigenesWerkzeug: ein mandantenspezifischer +// Katalogeintrag (account_id gesetzt) ist über die Betreiber-Seiten +// nicht erreichbar — das bleibt Sache des jeweiligen Mandanten. +func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + acc, err := fs.CreateAccount(context.Background(), "Mandant mit eigenem Werkzeug") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + eigenesID := "eigenes-werkzeug" + fs.werkzeuge[eigenesID] = store.Werkzeug{ID: eigenesID, AccountID: &acc.ID, Name: "Mandanten-eigenes Tool"} + + resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+eigenesID) + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index bc61c39..5cd8c28 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -50,7 +50,11 @@ type Store interface { ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) + ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) + CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) + UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) + DeleteWerkzeug(ctx context.Context, id string) error CurrentKatalogVersion(ctx context.Context) (string, error) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) @@ -113,6 +117,12 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog)) + mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe)) + mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm)) + mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate)) + mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm)) + mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate)) + mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete)) mux.Handle("GET /static/", http.FileServerFS(staticFS)) s.mux = mux diff --git a/internal/web/server_test.go b/internal/web/server_test.go index b5bfc8d..b83cca8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -269,6 +269,58 @@ func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID strin return out, nil } +func (f *fakeStore) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Werkzeug + for _, w := range f.werkzeuge { + if w.AccountID == nil { + out = append(out, w) + } + } + return out, nil +} + +func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) { + f.mu.Lock() + defer f.mu.Unlock() + w := store.Werkzeug{ + ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter, + Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL, + TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage, + Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen, + LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + f.werkzeuge[w.ID] = w + return w, nil +} + +func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) { + f.mu.Lock() + defer f.mu.Unlock() + w, ok := f.werkzeuge[id] + if !ok { + return store.Werkzeug{}, store.ErrNotFound + } + w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort + w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL + w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage + w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen + w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now() + f.werkzeuge[id] = w + return w, nil +} + +func (f *fakeStore) DeleteWerkzeug(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.werkzeuge[id]; !ok { + return store.ErrNotFound + } + delete(f.werkzeuge, id) + return nil +} + func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() @@ -666,7 +718,7 @@ func TestHandleIndexRendersForMitarbeiter(t *testing.T) { func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) { s, _, cookie := newAuthedTestServer(t) - for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} { + for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log", "/betreiber/werkzeuge"} { resp := getWithCookie(t, s, cookie, path) if resp.Code != http.StatusNotFound { t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code) diff --git a/internal/web/templates/betreiber_dashboard.html b/internal/web/templates/betreiber_dashboard.html index 72eeeb7..41f281c 100644 --- a/internal/web/templates/betreiber_dashboard.html +++ b/internal/web/templates/betreiber_dashboard.html @@ -7,6 +7,7 @@

Plattform

diff --git a/internal/web/templates/betreiber_werkzeug_form.html b/internal/web/templates/betreiber_werkzeug_form.html new file mode 100644 index 0000000..6d99d8b --- /dev/null +++ b/internal/web/templates/betreiber_werkzeug_form.html @@ -0,0 +1,65 @@ +{{define "betreiber-werkzeug-form"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Werkzeugkatalog

+

{{.Title}}

+

+ Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out, + Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind + Pflicht. +

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + +
+ Verarbeitungsort + {{$gewaehlt := .Verarbeitungsort}} + {{range .Verarbeitungsorte}} + + {{end}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+{{if .ID}} +
+ +
+{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/betreiber_werkzeuge.html b/internal/web/templates/betreiber_werkzeuge.html new file mode 100644 index 0000000..a1b22c4 --- /dev/null +++ b/internal/web/templates/betreiber_werkzeuge.html @@ -0,0 +1,26 @@ +{{define "betreiber-werkzeuge"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Plattform

+

Werkzeugkatalog

+{{if not .Werkzeuge}} +

Noch keine zentralen Katalogeinträge.

+{{else}} + +{{end}} +

Werkzeug anlegen

+
+ + +{{end}}