feat: Ebene 4 vollständig steuerbar machen — Abteilungen, Werkzeug-Sperrungen, Nutzer-Deaktivierung
Deklarix soll ein buchbarer Service werden — dafür muss jede Entität im
Datenmodell über das Frontend steuerbar sein, nicht nur einsehbar.
Schließt drei konkrete Lücken:
- Abteilungen (GET /verwaltung/abteilungen, anlegen/löschen) — ohne
diese Seite blieb die Abteilung-Auswahl im Antrag-Fragebogen leer
und unbenutzbar, das war ein Funktionsdefizit, kein Komfortfehler.
- Eigene Werkzeug-Sperrungen (GET /verwaltung/werkzeuge) — ein Mandant
kann einen zentralen Katalogeintrag jetzt für sich sperren/entsperren,
ohne den zentralen Katalog selbst zu verändern.
- Nutzer-Deaktivierung (POST /verwaltung/nutzer/{id}/deaktivieren bzw.
.../aktivieren, neue Spalte app_user.active, Migration 0012). Nutzer
werden nicht gelöscht (Fremdschlüssel auf antrag/entscheidung/
audit_log würden das verhindern und die Historie zerstören) —
deaktivierte Logins können sich nicht mehr anmelden und verlieren
eine laufende Sitzung sofort. Ein Admin kann sich nicht selbst
deaktivieren.
Zusätzlich: store.ListAktiveGenehmigungenForAccount als Grundlage für
die Wiedervorlage (Schritt 7, Web-Layer folgt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
54
CLAUDE.md
54
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_user DROP COLUMN active;
|
||||
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
96
internal/web/abteilung_handlers_test.go
Normal file
96
internal/web/abteilung_handlers_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
75
internal/web/mandant_werkzeug_handlers.go
Normal file
75
internal/web/mandant_werkzeug_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
@@ -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(), `<span class="status">gesperrt</span>`) {
|
||||
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(), `<span class="status">gesperrt</span>`) {
|
||||
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(), `<span class="status">gesperrt</span>`) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
102
internal/web/nutzer_aktivierung_test.go
Normal file
102
internal/web/nutzer_aktivierung_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
32
internal/web/templates/abteilung_liste.html
Normal file
32
internal/web/templates/abteilung_liste.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{define "abteilung-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Abteilungen</h1>
|
||||
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if not .Abteilungen}}
|
||||
<p class="hinweis">Noch keine Abteilungen.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Abteilungen}}
|
||||
<li>
|
||||
{{.Name}}
|
||||
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
|
||||
<button type="submit">Löschen</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<form method="post" action="/verwaltung/abteilungen">
|
||||
<label for="name">Neue Abteilung</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
<button type="submit">Anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -13,6 +13,8 @@
|
||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
38
internal/web/templates/mandant_werkzeuge.html
Normal file
38
internal/web/templates/mandant_werkzeuge.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{{define "mandant-werkzeuge"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
<p class="hinweis">
|
||||
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.
|
||||
</p>
|
||||
{{if not .Werkzeuge}}
|
||||
<p class="hinweis">Der zentrale Katalog ist noch leer.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Werkzeuge}}
|
||||
<li>
|
||||
{{.Name}} · {{.Anbieter}}
|
||||
{{if .Gesperrt}}
|
||||
<span class="status">gesperrt</span>
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/entsperren" style="display:inline">
|
||||
<button type="submit">Entsperren</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/verwaltung/werkzeuge/{{.ID}}/sperren" style="display:inline">
|
||||
<input type="text" name="grund" placeholder="Grund (optional)">
|
||||
<button type="submit">Sperren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -7,7 +7,22 @@
|
||||
<h1>Nutzerverwaltung</h1>
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Nutzer}}
|
||||
<li>{{.Email}} <span class="status">{{.Role}}</span></li>
|
||||
<li>
|
||||
{{.Email}} <span class="status">{{.Role}}</span>
|
||||
{{if .Active}}
|
||||
<span class="status status-published">aktiv</span>
|
||||
{{if not .IstSelbst}}
|
||||
<form method="post" action="/verwaltung/nutzer/{{.ID}}/deaktivieren" style="display:inline">
|
||||
<button type="submit">Deaktivieren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="status">deaktiviert</span>
|
||||
<form method="post" action="/verwaltung/nutzer/{{.ID}}/aktivieren" style="display:inline">
|
||||
<button type="submit">Aktivieren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p>
|
||||
|
||||
Reference in New Issue
Block a user