From 7397f700685ae42d081864a1b5498f66f9ebe2b8 Mon Sep 17 00:00:00 2001 From: noroot Date: Sun, 30 Aug 2026 13:39:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Firmen-CRUD=20f=C3=BCr=20den=20Betreibe?= =?UTF-8?q?r=20(anlegen,=20umbenennen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 20 +++- internal/store/account.go | 14 +++ internal/store/auth_test.go | 33 +++++++ internal/web/betreiber_account_crud_test.go | 98 +++++++++++++++++++ internal/web/betreiber_handlers.go | 92 +++++++++++++++++ internal/web/server.go | 4 + internal/web/server_test.go | 12 +++ .../templates/betreiber_account_detail.html | 6 ++ .../web/templates/betreiber_account_neu.html | 29 ++++++ .../web/templates/betreiber_accounts.html | 1 + 10 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 internal/web/betreiber_account_crud_test.go create mode 100644 internal/web/templates/betreiber_account_neu.html diff --git a/CLAUDE.md b/CLAUDE.md index e90e18c..6a54673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,15 +129,27 @@ 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, ein echtes Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am 2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus- -Zugangsdaten), Account-Verwaltung durch den Betreiber jenseits des -Support-Logins (Bearbeiten/Sperren eines Accounts selbst hängt weiter -an der Abrechnungs-/Freischaltungs-Architektur, siehe Offene Punkte: -`account.verified` wurde beim Produktwechsel sogar entfernt). +Zugangsdaten), Sperren/Deaktivieren eines Accounts durch den Betreiber +(hängt weiter an der Abrechnungs-/Freischaltungs-Architektur, siehe +Offene Punkte: `account.verified` wurde beim Produktwechsel sogar +entfernt). **Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird Isolation in der Anwendungsschicht erzwungen (Handler vergleichen diff --git a/internal/store/account.go b/internal/store/account.go index 7a4a89b..bf04e70 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -45,6 +45,20 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) return a, nil } +// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der +// Betreiber-gestützten Anlage korrigieren). +func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) { + row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name) + a, err := scanAccount(row) + if errors.Is(err, pgx.ErrNoRows) { + return Account{}, ErrNotFound + } + if err != nil { + return Account{}, fmt.Errorf("store: update account: %w", err) + } + return a, nil +} + // GetAccount liest einen Mandanten anhand seiner ID. func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id) diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go index 8165fdd..d447c6a 100644 --- a/internal/store/auth_test.go +++ b/internal/store/auth_test.go @@ -30,6 +30,39 @@ func TestAccountCRUD(t *testing.T) { } } +func TestUpdateAccount(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + acc, err := s.CreateAccount(ctx, "Alter Name GmbH") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH") + if err != nil { + t.Fatalf("UpdateAccount: %v", err) + } + if updated.Name != "Neuer Name GmbH" { + t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name) + } + + got, err := s.GetAccount(ctx, acc.ID) + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + if got.Name != "Neuer Name GmbH" { + t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name) + } +} + +func TestUpdateAccountNotFound(t *testing.T) { + s := openTestStore(t) + _, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X") + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } +} + func TestGetAccountByEinladungToken(t *testing.T) { s := openTestStore(t) ctx := context.Background() diff --git a/internal/web/betreiber_account_crud_test.go b/internal/web/betreiber_account_crud_test.go new file mode 100644 index 0000000..370664f --- /dev/null +++ b/internal/web/betreiber_account_crud_test.go @@ -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) + } +} diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go index 55b586c..bad1881 100644 --- a/internal/web/betreiber_handlers.go +++ b/internal/web/betreiber_handlers.go @@ -75,6 +75,98 @@ 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.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 { ID string Email string diff --git a/internal/web/server.go b/internal/web/server.go index 0f31ddc..700c7bd 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -29,6 +29,7 @@ var staticFS embed.FS // einen Fake statt eine echte Datenbank zu brauchen. type Store interface { CreateAccount(ctx context.Context, name string) (store.Account, error) + UpdateAccount(ctx context.Context, id, name string) (store.Account, error) GetAccount(ctx context.Context, id string) (store.Account, error) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error @@ -152,7 +153,10 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete)) mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) + mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm)) + mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) + mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename)) mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls)) mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog)) mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index c4e0192..71459e6 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -70,6 +70,18 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou return acc, nil } +func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) { + f.mu.Lock() + defer f.mu.Unlock() + acc, ok := f.accounts[id] + if !ok { + return store.Account{}, store.ErrNotFound + } + acc.Name = name + f.accounts[id] = acc + return acc, nil +} + func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/web/templates/betreiber_account_detail.html b/internal/web/templates/betreiber_account_detail.html index ee7c806..2cf42e7 100644 --- a/internal/web/templates/betreiber_account_detail.html +++ b/internal/web/templates/betreiber_account_detail.html @@ -8,6 +8,12 @@

{{.Name}}

Angelegt am {{.CreatedAt}}

+
+ + + +
+

Nutzer

{{end}} +

Firma anlegen