diff --git a/CLAUDE.md b/CLAUDE.md index b7628a3..af1adad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,20 +72,46 @@ ist der Nachfolger dessen, was früher (vor dem Produktwechsel) Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware je Handler), nicht als grob unterschiedene Seitenbereiche. -**Ebene 4 — Nutzerverwaltung ist umgesetzt** (`internal/web/admin_handlers.go`, -Middleware `requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins -des eigenen Mandanten, `GET/POST /verwaltung/nutzer/neu` legt einen -weiteren Login mit einer der vier Mandanten-Rollen an (`mitarbeiter`, -`verantwortlicher`, `pruefer`, `admin` — `betreiber` kann kein -Mandanten-Admin vergeben, das ist Ebene 5). Es gibt noch **keine** -Einladungsmail — der Admin setzt das Initialpasswort direkt im -Formular und gibt es auf einem anderen Weg weiter (konsistent mit dem -Onboarding-Stand unten: Einladungslink/CSV-Import/SSO sind noch nicht -gebaut). Das schließt die Lücke, dass Ebene 3 (Fachebene) bisher nur -über einen manuellen SQL-Insert nutzbar war, weil die Firma- -Registrierung ausschließlich einen `admin`-Nutzer erzeugt. Abteilungen- -Verwaltung, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen und -Rechnungsdaten (Ebene 4 laut Tabelle oben) sind weiterhin nicht gebaut. +**Ebene 4 ist inzwischen zu großen Teilen umgesetzt** (Anspruch: alles, +was im Datenmodell existiert, muss über das Frontend steuerbar sein, +nicht nur einsehbar — Deklarix soll ein buchbarer Service werden, ein +reiner Anzeige-Screen reicht nicht): + +- **Nutzerverwaltung** (`internal/web/admin_handlers.go`, Middleware + `requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins des + eigenen Mandanten mit Status (aktiv/deaktiviert), `GET/POST + /verwaltung/nutzer/neu` legt einen weiteren Login mit einer der vier + Mandanten-Rollen an (`mitarbeiter`, `verantwortlicher`, `pruefer`, + `admin` — `betreiber` kann kein Mandanten-Admin vergeben, das ist + Ebene 5). Es gibt noch **keine** Einladungsmail — der Admin setzt das + Initialpasswort direkt im Formular. Nutzer werden **nicht gelöscht** + (`app_user` wird von `antrag`/`entscheidung`/`audit_log` per Foreign + Key referenziert — ein Hard-Delete würde die Historie zerstören), + sondern über `POST /verwaltung/nutzer/{id}/deaktivieren` bzw. + `.../aktivieren` (de-)aktiviert (Spalte `app_user.active`, Migration + 0012). Ein deaktivierter Login kann sich nicht mehr anmelden + (`handleLogin` prüft `Active` erst NACH der Passwortprüfung, um keine + Kontoexistenz zu verraten) und verliert eine bereits laufende Sitzung + sofort (`authenticate`-Middleware prüft `Active` bei jedem Request). + Ein Admin kann sich nicht selbst deaktivieren (Aussperr-Schutz). +- **Abteilungen** (`GET /verwaltung/abteilungen`, anlegen + löschen): + reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese + Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und + unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit. +- **Eigene Werkzeug-Sperrungen** (`internal/web/mandant_werkzeug_handlers.go`, + `GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen + Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne + den zentralen Katalog selbst zu verändern — das bleibt Sache der + Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). + +**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, eigene +Werkzeug-EINTRÄGE eines Mandanten (nur Sperrungen zentraler Einträge +sind umgesetzt, `account_id`-gesetzte eigene Katalogeinträge fehlen +noch), Rechnungsdaten/Abrechnung (kein Abo-System, bewusst "Nicht bauen +v1"), Account-Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts +nur lesend an — Bearbeiten/Sperren hängt an der noch nicht getroffenen +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/auth_test.go b/internal/store/auth_test.go index 8748b6d..9020ff4 100644 --- a/internal/store/auth_test.go +++ b/internal/store/auth_test.go @@ -52,6 +52,49 @@ func TestUserCRUD(t *testing.T) { if byID.Email != "team@example.com" || byID.AccountID != accID { t.Fatalf("GetUser = %+v, unerwartete Werte", byID) } + if !byID.Active { + t.Error("expected a newly created user to be Active by default") + } +} + +func TestSetUserActive(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + if err := s.SetUserActive(ctx, user.ID, false); err != nil { + t.Fatalf("SetUserActive (false): %v", err) + } + deaktiviert, err := s.GetUser(ctx, user.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if deaktiviert.Active { + t.Fatal("expected the user to be inactive") + } + + if err := s.SetUserActive(ctx, user.ID, true); err != nil { + t.Fatalf("SetUserActive (true): %v", err) + } + reaktiviert, err := s.GetUser(ctx, user.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if !reaktiviert.Active { + t.Fatal("expected the user to be active again") + } +} + +func TestSetUserActiveNotFound(t *testing.T) { + s := openTestStore(t) + err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false) + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } } func TestGetUserByEmailNotFound(t *testing.T) { diff --git a/internal/store/entscheidung.go b/internal/store/entscheidung.go index 71174ed..26a9ad1 100644 --- a/internal/store/entscheidung.go +++ b/internal/store/entscheidung.go @@ -109,3 +109,38 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str } return e, nil } + +// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen +// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage +// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende +// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit +// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche +// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug +// zum Mandanten läuft über den zugehörigen antrag. +func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung, + e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at + FROM entscheidung e + JOIN antrag a ON a.id = e.antrag_id + WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen') + ORDER BY e.created_at DESC + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err) + } + defer rows.Close() + + var out []Entscheidung + for rows.Next() { + e, err := scanEntscheidung(rows) + if err != nil { + return nil, fmt.Errorf("store: scan entscheidung: %w", err) + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err) + } + return out, nil +} diff --git a/internal/store/entscheidung_test.go b/internal/store/entscheidung_test.go index 0878b47..59f66c4 100644 --- a/internal/store/entscheidung_test.go +++ b/internal/store/entscheidung_test.go @@ -113,3 +113,41 @@ func TestEntscheidungIsAppendOnly(t *testing.T) { t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger") } } + +func TestListAktiveGenehmigungenForAccount(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userID := testUserID(t, s, accID) + + antragGenehmigt, b1 := testAntragMitBewertung(t, s, accID, userID) + if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{ + AntragID: antragGenehmigt.ID, BewertungID: b1.ID, EntscheiderUserID: userID, Entscheidung: "genehmigt", + }); err != nil { + t.Fatalf("CreateEntscheidung (genehmigt): %v", err) + } + + antragAbgelehnt, b2 := testAntragMitBewertung(t, s, accID, userID) + if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{ + AntragID: antragAbgelehnt.ID, BewertungID: b2.ID, EntscheiderUserID: userID, Entscheidung: "abgelehnt", + }); err != nil { + t.Fatalf("CreateEntscheidung (abgelehnt): %v", err) + } + + list, err := s.ListAktiveGenehmigungenForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListAktiveGenehmigungenForAccount: %v", err) + } + if len(list) != 1 || list[0].AntragID != antragGenehmigt.ID { + t.Fatalf("ListAktiveGenehmigungenForAccount = %+v, want exactly the genehmigt entscheidung", list) + } + + otherAcc := testAccountID(t, s) + otherList, err := s.ListAktiveGenehmigungenForAccount(ctx, otherAcc) + if err != nil { + t.Fatalf("ListAktiveGenehmigungenForAccount (other): %v", err) + } + if len(otherList) != 0 { + t.Fatalf("expected no genehmigungen for a different tenant, got %+v", otherList) + } +} diff --git a/internal/store/migrations/0012_app_user_active.down.sql b/internal/store/migrations/0012_app_user_active.down.sql new file mode 100644 index 0000000..1c74a47 --- /dev/null +++ b/internal/store/migrations/0012_app_user_active.down.sql @@ -0,0 +1 @@ +ALTER TABLE app_user DROP COLUMN active; diff --git a/internal/store/migrations/0012_app_user_active.up.sql b/internal/store/migrations/0012_app_user_active.up.sql new file mode 100644 index 0000000..0c9a9f7 --- /dev/null +++ b/internal/store/migrations/0012_app_user_active.up.sql @@ -0,0 +1,7 @@ +-- Nutzer können nicht gelöscht werden (app_user wird von antrag, +-- session, bewertung [über antrag], entscheidung, audit_log, +-- registereintrag [über antrag] per Foreign Key referenziert — ein +-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren. +-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber +-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar. +ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true; diff --git a/internal/store/user.go b/internal/store/user.go index bb56fd9..092a2ab 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -15,28 +15,41 @@ import ( // Login: falsche E-Mail vs. Datenbankfehler). var ErrNotFound = errors.New("store: nicht gefunden") -// User ist ein Login innerhalb eines Account (Mandanten). +// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden +// nicht gelöscht (app_user wird von antrag/session/entscheidung/ +// audit_log per Foreign Key referenziert — ein Hard-Delete würde die +// Historie zerstören), sondern über Active deaktiviert. type User struct { ID string AccountID string Email string PasswordHash string Role string + Active bool CreatedAt time.Time } +const userColumns = `id, account_id, email, password_hash, role, active, created_at` + +func scanUser(row interface { + Scan(dest ...any) error +}) (User, error) { + var u User + err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt) + return u, err +} + // CreateUser legt einen neuen Nutzer innerhalb eines Accounts an. // passwordHash muss bereits gehasht sein (siehe internal/auth) — store // speichert nur, es hasht nicht selbst. func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) { - var u User - err := s.Pool.QueryRow(ctx, ` + row := s.Pool.QueryRow(ctx, ` INSERT INTO app_user (account_id, email, password_hash, role) VALUES ($1, $2, $3, $4) - RETURNING id, account_id, email, password_hash, role, created_at - `, accountID, email, passwordHash, role).Scan( - &u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt, + RETURNING `+userColumns, + accountID, email, passwordHash, role, ) + u, err := scanUser(row) if err != nil { return User{}, fmt.Errorf("store: create user: %w", err) } @@ -46,11 +59,8 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, // GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse. // Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler). func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { - var u User - err := s.Pool.QueryRow(ctx, ` - SELECT id, account_id, email, password_hash, role, created_at - FROM app_user WHERE email = $1 - `, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt) + row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email) + u, err := scanUser(row) if errors.Is(err, pgx.ErrNoRows) { return User{}, ErrNotFound } @@ -62,11 +72,8 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) // GetUser liest einen Nutzer anhand seiner ID. func (s *Store) GetUser(ctx context.Context, id string) (User, error) { - var u User - err := s.Pool.QueryRow(ctx, ` - SELECT id, account_id, email, password_hash, role, created_at - FROM app_user WHERE id = $1 - `, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt) + row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id) + u, err := scanUser(row) if errors.Is(err, pgx.ErrNoRows) { return User{}, ErrNotFound } @@ -80,8 +87,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) { // Admin-Bereich (Account-Detailansicht). func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) { rows, err := s.Pool.Query(ctx, ` - SELECT id, account_id, email, password_hash, role, created_at - 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) if err != nil { return nil, fmt.Errorf("store: list users for account: %w", err) @@ -90,8 +96,8 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us var out []User for rows.Next() { - var u User - if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil { + u, err := scanUser(rows) + if err != nil { return nil, fmt.Errorf("store: scan user: %w", err) } out = append(out, u) @@ -101,3 +107,18 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us } return out, nil } + +// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer +// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber +// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen +// nachvollziehbar — deshalb (de-)aktivieren statt löschen. +func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error { + tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active) + if err != nil { + return fmt.Errorf("store: set user active: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/web/abteilung_handlers_test.go b/internal/web/abteilung_handlers_test.go new file mode 100644 index 0000000..d03a7a9 --- /dev/null +++ b/internal/web/abteilung_handlers_test.go @@ -0,0 +1,96 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestAdminCanCreateAndDeleteAbteilung(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/abteilungen", url.Values{"name": {"Vertrieb"}}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String()) + } + + list := getWithCookie(t, s, adminCookie, "/verwaltung/abteilungen") + if !strings.Contains(list.Body.String(), "Vertrieb") { + t.Errorf("expected Vertrieb in the Abteilungsliste, got: %s", list.Body.String()) + } + + var abtID string + for accID, abts := range fs.abteilungen { + _ = accID + for _, a := range abts { + if a.Name == "Vertrieb" { + abtID = a.ID + } + } + } + if abtID == "" { + t.Fatal("expected Vertrieb to exist in fakeStore") + } + + delResp := postForm(t, s, adminCookie, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{}) + if delResp.Code != http.StatusSeeOther { + t.Fatalf("delete status = %d, body: %s", delResp.Code, delResp.Body.String()) + } + if _, err := fs.GetAbteilung(context.Background(), abtID); err == nil { + t.Error("expected the Abteilung to be deleted") + } +} + +func TestNeueAntragFormZeigtAngelegteAbteilung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Marketing"}}) + + resp := getWithCookie(t, s, adminCookie, "/antraege/neu") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "Marketing") { + t.Errorf("expected the newly created Abteilung in the Fragebogen, got: %s", resp.Body.String()) + } +} + +func TestMitarbeiterCannotAccessAbteilungsverwaltung(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/abteilungen") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code) + } +} + +func TestAdminCannotDeleteForeignAbteilung(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/abteilungen", url.Values{"name": {"Nur A"}}) + var abtID string + for _, abts := range fs.abteilungen { + for _, a := range abts { + if a.Name == "Nur A" { + abtID = a.ID + } + } + } + adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin") + + resp := postForm(t, s, adminB, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{}) + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for a foreign account's Abteilung", resp.Code) + } + if _, err := fs.GetAbteilung(context.Background(), abtID); err != nil { + t.Error("expected the foreign Abteilung to still exist") + } +} diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go index ba09181..776fd9b 100644 --- a/internal/web/admin_handlers.go +++ b/internal/web/admin_handlers.go @@ -1,9 +1,9 @@ -// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen für GENAU EINEN -// Mandanten, siehe CLAUDE.md, Fünf Ebenen. Aktuell nur Nutzerverwaltung -// (Abteilungen, Anmeldeverfahren, Werkzeug-Freigaben, Rechnungsdaten -// sind spätere Ausbaustufen). Es gibt noch keinen Einladungslink/CSV- -// Import/SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier -// direkt ein Initialpasswort, wie schon bei der Firma-Registrierung. +// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für +// GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren, +// Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin +// spätere Ausbaustufen. Es gibt noch keinen Einladungslink/CSV-Import/ +// SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier direkt ein +// Initialpasswort, wie schon bei der Firma-Registrierung. package web import ( @@ -27,8 +27,11 @@ func istMandantenRolle(role string) bool { } type nutzerListItem struct { - Email string - Role string + ID string + Email string + Role string + Active bool + IstSelbst bool } type nutzerListeData struct { @@ -39,20 +42,61 @@ type nutzerListeData struct { // handleNutzerListe zeigt alle Logins des eigenen Mandanten. func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) { - users, err := s.store.ListUsersForAccount(r.Context(), currentUser(r).AccountID) + self := currentUser(r) + users, err := s.store.ListUsersForAccount(r.Context(), self.AccountID) if err != nil { http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) return } data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)} for _, u := range users { - data.Nutzer = append(data.Nutzer, nutzerListItem{Email: u.Email, Role: u.Role}) + data.Nutzer = append(data.Nutzer, nutzerListItem{ + ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID, + }) } if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } +// handleNutzerDeaktivieren sperrt einen Login des eigenen Mandanten — +// nicht löschen, siehe store.User (Fremdschlüssel auf antrag/entscheidung/ +// audit_log würden das verhindern und die Historie zerstören). Ein Admin +// kann sich nicht selbst deaktivieren, sonst könnte ein Mandant sich +// versehentlich komplett aussperren. +func (s *Server) handleNutzerDeaktivieren(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + self := currentUser(r) + if id == self.ID { + http.Error(w, "Der eigene Zugang kann nicht deaktiviert werden", http.StatusBadRequest) + return + } + target, err := s.store.GetUser(r.Context(), id) + if err != nil || target.AccountID != self.AccountID { + http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound) + return + } + if err := s.store.SetUserActive(r.Context(), id, false); err != nil { + http.Error(w, "Nutzer konnte nicht deaktiviert werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther) +} + +func (s *Server) handleNutzerAktivieren(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + target, err := s.store.GetUser(r.Context(), id) + if err != nil || target.AccountID != currentUser(r).AccountID { + http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound) + return + } + if err := s.store.SetUserActive(r.Context(), id, true); err != nil { + http.Error(w, "Nutzer konnte nicht aktiviert werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther) +} + type nutzerNeuData struct { Title string Nav navData @@ -106,3 +150,69 @@ func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther) } + +type abteilungListItem struct { + ID string + Name string +} + +type abteilungListeData struct { + Title string + Nav navData + Abteilungen []abteilungListItem + Error string +} + +// handleAbteilungListe zeigt alle Abteilungen des eigenen Mandanten +// mit einem Formular zum Anlegen weiterer — bewusst eine Seite statt +// Liste+Formular getrennt, da Abteilungen reine Stammdaten ohne +// weitere Felder sind (nur `name`). +func (s *Server) handleAbteilungListe(w http.ResponseWriter, r *http.Request) { + abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := abteilungListeData{Title: "Abteilungen", Nav: navFor(r)} + for _, a := range abteilungen { + data.Abteilungen = append(data.Abteilungen, abteilungListItem{ID: a.ID, Name: a.Name}) + } + if err := s.templates.ExecuteTemplate(w, "abteilung-liste", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +func (s *Server) handleAbteilungCreate(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 + } + if _, err := s.store.CreateAbteilung(r.Context(), currentUser(r).AccountID, name); err != nil { + http.Error(w, "Abteilung konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther) +} + +// handleAbteilungDelete entfernt eine Abteilung des eigenen Mandanten. +// Fremde Abteilungen (anderer Mandant) sind über die ID nicht löschbar +// — GetAbteilung prüft AccountID gegen den angemeldeten Mandanten, +// bevor gelöscht wird. +func (s *Server) handleAbteilungDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + abt, err := s.store.GetAbteilung(r.Context(), id) + if err != nil || abt.AccountID != currentUser(r).AccountID { + http.Error(w, "Abteilung nicht gefunden", http.StatusNotFound) + return + } + if err := s.store.DeleteAbteilung(r.Context(), id); err != nil { + http.Error(w, "Abteilung konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther) +} diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index 2733e3f..8060baf 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -93,6 +93,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"}) return } + // Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde + // diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass + // die E-Mail-Adresse zu einem (deaktivierten) Konto gehört. + if !user.Active { + s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"}) + return + } if err := s.startSession(w, r, user.ID); err != nil { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"}) diff --git a/internal/web/mandant_werkzeug_handlers.go b/internal/web/mandant_werkzeug_handlers.go new file mode 100644 index 0000000..5c6a3ee --- /dev/null +++ b/internal/web/mandant_werkzeug_handlers.go @@ -0,0 +1,75 @@ +// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md, +// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für +// sich sperren, ohne den zentralen Katalog selbst zu verändern (das +// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go). +package web + +import "net/http" + +type mandantWerkzeugItem struct { + ID string + Name string + Anbieter string + Gesperrt bool +} + +type mandantWerkzeugListeData struct { + Title string + Nav navData + Werkzeuge []mandantWerkzeugItem +} + +// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des +// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag. +func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) { + accountID := currentUser(r).AccountID + zentral, err := s.store.ListZentraleWerkzeuge(r.Context()) + if err != nil { + http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + sperren, err := s.store.ListWerkzeugSperrenForAccount(r.Context(), accountID) + if err != nil { + http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + gesperrt := make(map[string]bool, len(sperren)) + for _, sp := range sperren { + gesperrt[sp.WerkzeugID] = true + } + + data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)} + for _, wz := range zentral { + data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{ + ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID], + }) + } + if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag +// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt +// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für +// diesen einen Mandanten. +func (s *Server) handleMandantWerkzeugSperren(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + grund := r.FormValue("grund") + if _, err := s.store.CreateWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id"), grund); err != nil { + http.Error(w, "Werkzeug konnte nicht gesperrt werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther) +} + +func (s *Server) handleMandantWerkzeugEntsperren(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id")); err != nil { + http.Error(w, "Sperrung konnte nicht aufgehoben werden: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther) +} diff --git a/internal/web/mandant_werkzeug_handlers_test.go b/internal/web/mandant_werkzeug_handlers_test.go new file mode 100644 index 0000000..d10871b --- /dev/null +++ b/internal/web/mandant_werkzeug_handlers_test.go @@ -0,0 +1,66 @@ +package web_test + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/netcell-it/deklarix/internal/store" +) + +func TestAdminCanSperrenUndEntsperrenZentralesWerkzeug(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"} + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + + list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge") + if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") { + t.Fatalf("expected the zentrale werkzeug in the list, got: %s", list.Body.String()) + } + + resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{"grund": {"zu teuer"}}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("sperren status = %d, body: %s", resp.Code, resp.Body.String()) + } + afterSperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge") + if !strings.Contains(afterSperren.Body.String(), `gesperrt`) { + t.Errorf("expected the werkzeug to show as gesperrt, got: %s", afterSperren.Body.String()) + } + + resp = postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/entsperren", url.Values{}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("entsperren status = %d, body: %s", resp.Code, resp.Body.String()) + } + afterEntsperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge") + if strings.Contains(afterEntsperren.Body.String(), `gesperrt`) { + t.Errorf("expected the werkzeug to no longer show as gesperrt, got: %s", afterEntsperren.Body.String()) + } +} + +func TestSperreIstProMandantIsoliert(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"} + adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin") + adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin") + + postForm(t, s, adminA, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{}) + + respB := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge") + if strings.Contains(respB.Body.String(), `gesperrt`) { + t.Errorf("expected Mandant B to see the werkzeug as unsperrt, got: %s", respB.Body.String()) + } +} + +func TestMitarbeiterCannotAccessWerkzeugverwaltung(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/werkzeuge") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code) + } +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 023a730..4f70ccf 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -34,7 +34,7 @@ func (s *Server) authenticate(r *http.Request) (store.User, bool) { } user, err := s.store.GetUser(r.Context(), sess.UserID) - if err != nil { + if err != nil || !user.Active { return store.User{}, false } return user, true diff --git a/internal/web/nutzer_aktivierung_test.go b/internal/web/nutzer_aktivierung_test.go new file mode 100644 index 0000000..4928007 --- /dev/null +++ b/internal/web/nutzer_aktivierung_test.go @@ -0,0 +1,102 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestAdminKannNutzerDeaktivierenUndReaktivieren(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{ + "email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"}, + }) + target, err := fs.GetUserByEmail(context.Background(), "weg@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + + resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{}) + if resp.Code != http.StatusSeeOther { + t.Fatalf("deaktivieren status = %d, body: %s", resp.Code, resp.Body.String()) + } + deaktiviert, err := fs.GetUser(context.Background(), target.ID) + if err != nil || deaktiviert.Active { + t.Fatalf("expected user to be inactive, got %+v (err %v)", deaktiviert, err) + } + + loginResp := postForm(t, s, nil, "/login", url.Values{"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}}) + if !strings.Contains(loginResp.Body.String(), "deaktiviert") { + t.Errorf("expected a deactivation message on login, got: %s", loginResp.Body.String()) + } + + reaktivierenResp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/aktivieren", url.Values{}) + if reaktivierenResp.Code != http.StatusSeeOther { + t.Fatalf("aktivieren status = %d, body: %s", reaktivierenResp.Code, reaktivierenResp.Body.String()) + } + reaktiviert, err := fs.GetUser(context.Background(), target.ID) + if err != nil || !reaktiviert.Active { + t.Fatalf("expected user to be active again, got %+v (err %v)", reaktiviert, err) + } +} + +func TestAdminKannSichNichtSelbstDeaktivieren(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") + self, err := fs.GetUserByEmail(context.Background(), "admin@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + + resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+self.ID+"/deaktivieren", url.Values{}) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for self-deactivation", resp.Code) + } +} + +func TestDeaktivierterNutzerVerliertLaufendeSitzungSofort(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) + } + mitarbeiterCookie := seedUserInAccount(t, fs, admin.AccountID, "mitarbeiter@example.com", "mitarbeiter") + + if resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege"); resp.Code != http.StatusOK { + t.Fatalf("expected active session to work, status = %d", resp.Code) + } + + target, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{}) + + resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege") + if resp.Code != http.StatusSeeOther { + t.Fatalf("expected the deactivated user's session to be rejected, status = %d", resp.Code) + } +} + +func TestAdminCannotDeactivateForeignAccountUser(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin") + seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin") + targetB, err := fs.GetUserByEmail(context.Background(), "admin-b@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + + resp := postForm(t, s, adminA, "/verwaltung/nutzer/"+targetB.ID+"/deaktivieren", url.Values{}) + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for a foreign account's user", resp.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index d2e3011..eb86b63 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -35,6 +35,7 @@ type Store interface { GetUserByEmail(ctx context.Context, email string) (store.User, error) GetUser(ctx context.Context, id string) (store.User, error) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) + SetUserActive(ctx context.Context, id string, active bool) error CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) GetSession(ctx context.Context, token string) (store.Session, error) DeleteSession(ctx context.Context, token string) error @@ -42,6 +43,8 @@ type Store interface { ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) + CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error) + DeleteAbteilung(ctx context.Context, id string) error CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) GetAntrag(ctx context.Context, id string) (store.Antrag, error) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) @@ -51,6 +54,9 @@ type Store interface { ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) + CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error) + DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error + ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, 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) @@ -61,6 +67,7 @@ type Store interface { CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) + ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) @@ -120,6 +127,14 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe)) mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm)) 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}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren)) + mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe)) + mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate)) + mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete)) + 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}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren)) mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 160405f..6363c51 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -37,6 +37,7 @@ type fakeStore struct { bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege + werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen } func newFakeStore() *fakeStore { @@ -51,6 +52,7 @@ func newFakeStore() *fakeStore { bewertungen: map[string][]store.Bewertung{}, entscheidungen: map[string][]store.Entscheidung{}, registereintraege: map[string][]store.Registereintrag{}, + werkzeugSperren: map[string][]store.WerkzeugSperre{}, } } @@ -95,7 +97,7 @@ func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHa } u := store.User{ ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash, - Role: role, CreatedAt: time.Now(), + Role: role, Active: true, CreatedAt: time.Now(), } f.users[u.ID] = u f.usersByEmail[email] = u.ID @@ -122,6 +124,18 @@ func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) return u, nil } +func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) error { + f.mu.Lock() + defer f.mu.Unlock() + u, ok := f.users[id] + if !ok { + return store.ErrNotFound + } + u.Active = active + f.users[id] = u + return nil +} + func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) { f.mu.Lock() defer f.mu.Unlock() @@ -202,6 +216,28 @@ func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilun return store.Abteilung{}, store.ErrNotFound } +func (f *fakeStore) CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error) { + f.mu.Lock() + defer f.mu.Unlock() + a := store.Abteilung{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()} + f.abteilungen[accountID] = append(f.abteilungen[accountID], a) + return a, nil +} + +func (f *fakeStore) DeleteAbteilung(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + for accID, list := range f.abteilungen { + for i, a := range list { + if a.ID == id { + f.abteilungen[accID] = append(list[:i], list[i+1:]...) + return nil + } + } + } + return store.ErrNotFound +} + func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) { f.mu.Lock() defer f.mu.Unlock() @@ -296,6 +332,33 @@ func (f *fakeStore) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug return out, nil } +func (f *fakeStore) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error) { + f.mu.Lock() + defer f.mu.Unlock() + sp := store.WerkzeugSperre{ID: f.newID(), AccountID: accountID, WerkzeugID: werkzeugID, Grund: grund, GesperrtAm: time.Now()} + f.werkzeugSperren[accountID] = append(f.werkzeugSperren[accountID], sp) + return sp, nil +} + +func (f *fakeStore) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error { + f.mu.Lock() + defer f.mu.Unlock() + list := f.werkzeugSperren[accountID] + for i, sp := range list { + if sp.WerkzeugID == werkzeugID { + f.werkzeugSperren[accountID] = append(list[:i], list[i+1:]...) + return nil + } + } + return store.ErrNotFound +} + +func (f *fakeStore) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.werkzeugSperren[accountID], nil +} + func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() @@ -400,6 +463,26 @@ func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID return es[len(es)-1], nil } +func (f *fakeStore) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Entscheidung + for antragID, a := range f.antraege { + if a.AccountID != accountID { + continue + } + es := f.entscheidungen[antragID] + if len(es) == 0 { + continue + } + latest := es[len(es)-1] + if latest.Entscheidung == "genehmigt" || latest.Entscheidung == "genehmigt_mit_auflagen" { + out = append(out, latest) + } + } + return out, nil +} + func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/web/templates/abteilung_liste.html b/internal/web/templates/abteilung_liste.html new file mode 100644 index 0000000..997ec7e --- /dev/null +++ b/internal/web/templates/abteilung_liste.html @@ -0,0 +1,32 @@ +{{define "abteilung-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Abteilungen

+

Stammdaten für den Fragebogen (Feld "Abteilung").

+{{if .Error}}

{{.Error}}

{{end}} +{{if not .Abteilungen}} +

Noch keine Abteilungen.

+{{else}} + +{{end}} +
+ + + +
+
+ + +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index c376285..6e33805 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -13,6 +13,8 @@ {{if .IsFachebene}}Posteingang{{end}} {{if .IsFachebene}}Register{{end}} {{if .IsAdmin}}Nutzerverwaltung{{end}} + {{if .IsAdmin}}Abteilungen{{end}} + {{if .IsAdmin}}Werkzeugkatalog{{end}} {{if .IsBetreiber}}Plattform{{end}}
diff --git a/internal/web/templates/mandant_werkzeuge.html b/internal/web/templates/mandant_werkzeuge.html new file mode 100644 index 0000000..b155411 --- /dev/null +++ b/internal/web/templates/mandant_werkzeuge.html @@ -0,0 +1,38 @@ +{{define "mandant-werkzeuge"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Werkzeugkatalog

+

+ Der zentrale Katalog 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. +

+{{if not .Werkzeuge}} +

Der zentrale Katalog ist noch leer.

+{{else}} + +{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/nutzer_liste.html b/internal/web/templates/nutzer_liste.html index bbf2ece..5b454f5 100644 --- a/internal/web/templates/nutzer_liste.html +++ b/internal/web/templates/nutzer_liste.html @@ -7,7 +7,22 @@

Nutzerverwaltung

Nutzer anlegen