Compare commits

...

4 Commits

Author SHA1 Message Date
noroot
eb99891e38 feat: eigene Werkzeugkatalog-Einträge für Mandanten
Ein Mandant kann jetzt zusätzlich zum Sperren zentraler Katalogeinträge
auch eigene, nur für sich sichtbare Werkzeuge anlegen/bearbeiten/
löschen (GET/POST /verwaltung/werkzeuge/neu bzw. /{id}, POST
/{id}/loeschen — werkzeug.account_id = eigener Account). Nutzt
dasselbe Formular wie der zentrale Katalog des Betreibers
(werkzeugFormData/betreiber-werkzeug-form.html, ein neues ActionBase-
Feld unterscheidet die Ziel-URL); ein zentraler oder fremder Eintrag
bleibt über diese Route unerreichbar (404). Schließt die letzte
dokumentierte Lücke bei "eigene Werkzeug-Freigaben/-Sperrungen" (Ebene 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:23:43 +02:00
noroot
36af1bf288 feat: responsives Frontend — Seitenleiste ab Desktop, Tabellen-Styling
Nav wird jetzt für alle Seiten aus einer einzigen Stelle
(layout.html-Block) gerendert: auf dem Handy ein per Checkbox-Hack
aufklappbares Menü (kein JavaScript), ab 960px Breite per CSS in eine
permanente, dunkle Seitenleiste im enconf-Look verwandelt — dieselbe
Markup-Struktur bedient beide Layouts, kein separates Desktop-Template.
<details>/<summary> wurde bewusst NICHT verwendet: aktuelle
Chromium-Versionen steuern dessen Auf-/Zuklapp-Zustand über eine
CSS-resistente interne Animation (auch mit !important nicht
überschreibbar), per Headless-Screenshot verifiziert.

Zusätzlich: Tabellen (vorher komplett ungestylt, betraf v. a. das
Register) haben jetzt echte Gestaltung inkl. horizontal scrollbarem
Wrapper für schmale Bildschirme, Listenkarten ohne Link-Wrapper
(Nutzer-/Abteilungsliste) bekommen dieselbe Kartenoptik wie
verlinkte Einträge, und destruktive Aktionen (Löschen/Deaktivieren)
sind jetzt visuell von primären Aktionen unterschieden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:06:14 +02:00
noroot
5f6502bb41 feat: Wiedervorlage und Katalog-Benachrichtigung (Schritt 7 der Baureihenfolge)
GET /wiedervorlage (Ebene 3) zeigt aktive Genehmigungen, die erneut
geprüft werden sollten: Ablaufdatum erreicht oder innerhalb von 30
Tagen, das zugesagte Werkzeug wurde aus dem Katalog entfernt, oder der
aktuelle Katalogeintrag weicht bei AVV/Training-Standard/
Verarbeitungsort vom zum Entscheidungszeitpunkt eingefrorenen Snapshot
ab. Keine E-Mail-Infrastruktur vorhanden — Benachrichtigung ist bewusst
eine In-App-Liste. Damit ist die ursprüngliche Baureihenfolge
(Schritt 1-7) vollständig umgesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 14:12:44 +02:00
noroot
22f748d66c 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>
2026-08-29 14:07:47 +02:00
28 changed files with 1705 additions and 94 deletions

134
CLAUDE.md
View File

@@ -72,20 +72,52 @@ 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 und -Einträge** (`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`). Zusätzlich kann
ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/
bearbeiten/löschen (`GET/POST /verwaltung/werkzeuge/neu`, `GET/POST
/verwaltung/werkzeuge/{id}`, `POST /verwaltung/werkzeuge/{id}/loeschen`
`werkzeug.account_id` = der eigene Account). Nutzt dasselbe Formular
wie der zentrale Katalog (`werkzeugFormData`/`betreiber-werkzeug-form.html`,
ein `ActionBase`-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL)
— ein zentraler oder fremder Eintrag ist über diese Route nicht
erreichbar (404).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, 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
@@ -220,7 +252,7 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt.
---
## Entscheidung, Register, Wiedervorlage (Schritt 5+6 erledigt, Schritt 7 geplant)
## Entscheidung, Register, Wiedervorlage (Schritt 5-7 erledigt)
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
@@ -251,9 +283,22 @@ erzeugt zusätzlich einen `audit_log`-Eintrag (Action
`antrag_entschieden`).
Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
`hochrisiko` 6 — `gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):**
die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
der eine aktive Genehmigung beruht (Wiedervorlage).
`hochrisiko` 6 — `gueltigkeitFuer`).
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
eines Mandanten (Join über `antrag.account_id`, da `entscheidung`
selbst kein `account_id` trägt).
**Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
erzeugt automatisch einen `registereintrag` (append-only, Migration
@@ -293,6 +338,49 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
---
## Frontend
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
buchbarer Service werden soll): **für Browser UND Mobile optimiert,
und jede Entität im Datenmodell muss über das Frontend steuerbar sein**
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
Stack bleibt `html/template` + htmx, kein React/Vue/SPA (siehe
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
**Navigation** (`internal/web/templates/layout.html`, `"nav"`-Block,
einmal definiert und von jeder Seite eingebunden — Änderungen hier
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
`<label for=...>` als Klick-Ziel) klappt auf dem Handy ein Menü auf,
ganz ohne JavaScript. **Nicht** das naheliegendere `<details>`/`<summary>`
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
Zustand über eine interne, mit CSS (auch nicht mit `!important`)
nicht überschreibbare Animation; ein Versuch, `.app-nav { display:
flex !important }` bei geschlossenem `<details>` zu erzwingen, bleibt
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
960px Breite (`@media (min-width: 960px)`, `app.css`) wird exakt
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
Seitenleiste (Farb-Tokens aus `design/enterprise.css` übernommen) —
kein separates Desktop-Template.
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
Scrollen auf dem Handy statt gequetschter Spalten).
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
authentifiziert abrufen, `/static/...`-Pfade im HTML auf eine absolute
`http://localhost:<port>/static/...`-URL umschreiben (Assets brauchen
kein Auth-Cookie), dann `chromium --headless --disable-gpu --no-sandbox
--window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html`
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
screenshotten. Für einen Menü-Aufklapp-Zustand: `checked`-Attribut
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
---
## Projektstruktur
```
@@ -307,7 +395,10 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
│ └── web/ # Handler, Templates
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
│ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
│ (Farben/Radius/Schatten), nicht direkt eingebunden
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
├── packaging/
│ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template
@@ -412,7 +503,10 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte
`go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor`
für deutsche Umlaute.)
7. Wiedervorlage und Katalog-Benachrichtigung
7. ~~Wiedervorlage und Katalog-Benachrichtigung~~**erledigt**
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE app_user DROP COLUMN active;

View 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;

View File

@@ -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
}

View 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")
}
}

View File

@@ -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 {
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)
}

View File

@@ -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"})

View File

@@ -72,9 +72,14 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
}
}
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
type werkzeugFormData struct {
Title string
Nav navData
ActionBase string
ID string // leer bei "neu"
Name string
Anbieter string
@@ -94,16 +99,16 @@ type werkzeugFormData struct {
}
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), Verarbeitungsorte: gueltigeVerarbeitungsorte}
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
// AccountID ist immer nil — diese Seiten pflegen ausschließlich den
// zentralen Katalog.
func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
// für einen mandantenspezifischen Eintrag (Ebene 4).
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
if err != nil {
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
@@ -121,7 +126,7 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
return store.WerkzeugInput{
AccountID: nil,
AccountID: accountID,
Name: name,
Anbieter: anbieter,
Verarbeitungsort: verarbeitungsort,
@@ -141,9 +146,9 @@ func werkzeugInputFromForm(r *http.Request) (store.WerkzeugInput, string) {
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
// statt das Formular zu leeren.
func werkzeugFormDataFromRequest(r *http.Request, title, id, errMsg string) werkzeugFormData {
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
return werkzeugFormData{
Title: title, Nav: navFor(r), ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
@@ -159,16 +164,16 @@ func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Re
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in, errMsg := werkzeugInputFromForm(r)
in, errMsg := werkzeugInputFromForm(r, nil)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", errMsg)
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -188,7 +193,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
return
}
data := werkzeugFormData{
Title: "Werkzeug bearbeiten", Nav: navFor(r), ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
@@ -216,16 +221,16 @@ func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Re
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in, errMsg := werkzeugInputFromForm(r)
in, errMsg := werkzeugInputFromForm(r, nil)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, errMsg)
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

View File

@@ -0,0 +1,103 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(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/werkzeuge", werkzeugForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
}
var id string
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
for wid, w := range fs.werkzeuge {
if w.AccountID != nil && *w.AccountID == admin.AccountID {
id = wid
}
}
if id == "" {
t.Fatal("expected an eigenes werkzeug to exist")
}
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
}
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
}
updateForm := werkzeugForm()
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
if updateResp.Code != http.StatusSeeOther {
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
}
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
}
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusSeeOther {
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
}
if _, ok := fs.werkzeuge[id]; ok {
t.Error("expected the eigenes werkzeug to be deleted")
}
}
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
}
}
func TestAdminCannotEditFremdesEigenesWerkzeug(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/werkzeuge", werkzeugForm())
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
var id string
for wid, w := range fs.werkzeuge {
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
id = wid
}
}
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
}
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusNotFound {
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
}
}

View File

@@ -0,0 +1,209 @@
// 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),
// UND eigene, mandantenspezifische Katalogeinträge pflegen
// (`werkzeug.account_id` = der eigene Account) — dieselbe
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
package web
import (
"net/http"
"strings"
)
type mandantWerkzeugItem struct {
ID string
Name string
Anbieter string
Gesperrt bool
}
type eigenesWerkzeugItem struct {
ID string
Name string
Anbieter string
}
type mandantWerkzeugListeData struct {
Title string
Nav navData
Werkzeuge []mandantWerkzeugItem
EigeneWerkzeuge []eigenesWerkzeugItem
}
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
// Löschen.
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
}
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Werkzeuge 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],
})
}
for _, wz := range eigene {
if wz.AccountID == nil {
continue // zentral, oben schon gelistet
}
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter})
}
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
func (s *Server) handleMandantWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := currentUser(r).AccountID
in, errMsg := werkzeugInputFromForm(r, &accountID)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
// anderen Mandanten.
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
accountID := currentUser(r).AccountID
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
data := werkzeugFormData{
Title: "Eigenes Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
// Katalogeintrag.
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
accountID := currentUser(r).AccountID
wz, err := s.store.GetWerkzeug(r.Context(), id)
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in, errMsg := werkzeugInputFromForm(r, &accountID)
if errMsg != "" {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
accountID := currentUser(r).AccountID
wz, err := s.store.GetWerkzeug(r.Context(), id)
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
}
// 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)
}

View 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)
}
}

View File

@@ -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

View 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)
}
}

View File

@@ -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)
@@ -117,9 +124,23 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
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 /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))

View File

@@ -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()

View File

@@ -1,9 +1,10 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
erweitert für größere Bildschirme. */
Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
@import url('/static/inter.css');
@@ -14,6 +15,7 @@
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--color-bg: #f8fafc;
--color-text: #334155;
@@ -27,6 +29,12 @@
--color-mittel-bg: #fffbeb;
--color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4;
--sidebar-width: 220px;
--sidebar-bg: linear-gradient(180deg, #0b1426 0%, #101d33 50%, #0d1829 100%);
--sidebar-text: #b6c2d9;
--sidebar-text-hover: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
}
* {
@@ -74,23 +82,109 @@ a {
margin: 0 auto;
}
nav {
/* ── Navigation ──────────────────────────────────────────────────────
Mobil: .topbar ist eine schmale, fixierte Kopfleiste mit einem
aufklappbaren <details>-Menü (siehe layout.html) — kein JavaScript,
das <details>-Element liefert Auf-/Zuklappen nativ. Ab Desktop-Breite
(siehe Media Query unten) wird dieselbe Markup-Struktur per CSS in
eine permanent sichtbare, dunkle Seitenleiste verwandelt. */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--color-border);
}
nav a {
color: var(--color-muted);
.brand {
font-weight: 600;
color: var(--color-heading);
text-decoration: none;
letter-spacing: -0.01em;
}
/* Checkbox-Hack: eine unsichtbare Checkbox plus ein <label for=...>
als Klick-/Tap-Ziel steuern per CSS-Geschwister-Selektor, ob .app-nav
sichtbar ist — funktioniert ohne JavaScript und ohne die Falle von
<details> (siehe Kommentar in layout.html). */
.nav-toggle {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.nav-toggle-label {
cursor: pointer;
padding: 8px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.nav-toggle:focus-visible ~ .nav-toggle-label {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
}
.nav-toggle:checked ~ .nav-toggle-label {
border-color: var(--branding-primary);
color: var(--branding-primary);
}
.app-nav {
display: none;
position: absolute;
top: calc(100% + 6px);
right: 16px;
z-index: 20;
flex-direction: column;
gap: 2px;
min-width: 220px;
padding: 8px;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.nav-toggle:checked ~ .app-nav {
display: flex;
}
.app-nav a {
padding: 10px 12px;
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
}
nav a:hover {
color: var(--color-text);
.app-nav a:hover {
background: var(--color-bg);
color: var(--branding-primary);
}
.app-nav form {
margin: 4px 0 0;
}
.app-nav button {
width: 100%;
margin-top: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
@@ -275,7 +369,12 @@ nav button {
gap: 8px;
}
.beitraege-liste li a {
.beitraege-liste li a,
/* Einträge ohne umschließenden Link (Zeilen mit Badge/Aktions-Buttons
statt Navigation, z. B. Nutzer-/Abteilungsliste) bekommen dieselbe
Karte direkt auf dem <li> — :has() unterscheidet beide Fälle, damit
ein verlinkter Eintrag nicht doppelt (li UND a) gerahmt wird. */
.beitraege-liste > li:not(:has(> a)) {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -288,6 +387,15 @@ nav button {
text-decoration: none;
}
.beitraege-liste > li:not(:has(> a)) form {
margin: 0;
flex: 0 0 auto;
}
.beitraege-liste > li:not(:has(> a)) button {
margin-top: 0;
}
.beteiligte {
list-style: none;
margin: 0 0 16px;
@@ -424,7 +532,45 @@ button.entfernen {
display: block;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
/* Tabellen (z. B. Register-Export-Ansicht) — bewusst horizontal
scrollbar in einem eigenen Wrapper statt schrumpfender Spalten: bei
neun Spalten wie im Register würde Schrumpfen nur unlesbaren Text
erzeugen, siehe .table-scroll in register_liste.html. */
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: var(--radius-md);
overflow: hidden;
font-size: 0.875rem;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
th {
background: var(--color-bg);
color: var(--color-heading);
font-weight: 600;
font-size: 0.8125rem;
}
tbody tr:hover {
background: var(--color-bg);
}
.table-scroll {
overflow-x: auto;
box-shadow: var(--shadow);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
.page {
@@ -435,3 +581,74 @@ button.entfernen {
font-size: 1.75rem;
}
}
/* Ab Desktop-Breite: dieselbe .topbar/.app-nav-Markup wird zu einer
permanent sichtbaren, dunklen Seitenleiste — kein separates
Desktop-Template, nur CSS. Der `details`/`summary`-Aufklapp-
Mechanismus wird hier überschrieben (Inhalt immer sichtbar,
summary versteckt), das Handy-Verhalten bleibt darunter unverändert. */
@media (min-width: 960px) {
.topbar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 8px;
padding: 20px 16px;
background: var(--sidebar-bg);
border-bottom: none;
border-right: 1px solid rgba(255, 255, 255, 0.08);
z-index: 10;
}
.brand {
color: #fff;
font-size: 1.125rem;
margin-bottom: 12px;
}
.nav-toggle-label {
display: none;
}
.app-nav {
display: flex;
position: static;
min-width: 0;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.app-nav a {
color: var(--sidebar-text);
}
.app-nav a:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
}
.app-nav button {
color: var(--sidebar-text);
border-color: rgba(255, 255, 255, 0.15);
}
.app-nav button:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
filter: none;
}
.page {
margin-left: var(--sidebar-width);
max-width: 1100px;
padding: 32px 40px;
width: 100%;
}
}

View 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" class="entfernen">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}}

View File

@@ -4,7 +4,7 @@
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber/werkzeuge">&larr; Werkzeugkatalog</a></p>
<p><a href="{{.ActionBase}}">&larr; Werkzeugkatalog</a></p>
<h1>{{.Title}}</h1>
<p class="hinweis">
Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out,
@@ -12,7 +12,7 @@
Pflicht.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="{{if .ID}}/betreiber/werkzeuge/{{.ID}}{{else}}/betreiber/werkzeuge{{end}}">
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
@@ -56,7 +56,7 @@
</form>
{{if .ID}}
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')">
<button type="submit">Werkzeug löschen</button>
<button type="submit" class="entfernen">Werkzeug löschen</button>
</form>
{{end}}
</div>

View File

@@ -6,18 +6,36 @@
<script src="/static/htmx.min.js"></script>
{{end}}
{{/* Eine einzige Nav-Definition für alle Seiten (siehe navData in
middleware.go) — auf dem Handy ein aufklappbares Menü (Checkbox-
Hack, kein JavaScript: das <details>-Element scheitert daran, dass
aktuelle Chromium-Versionen dessen Auf-/Zuklapp-Zustand über eine
interne, nicht per CSS überschreibbare Animation steuern), ab
Desktop-Breite per CSS in eine permanent sichtbare Seitenleiste
verwandelt. Dieselbe Markup-Struktur bedient beide Layouts, siehe
app.css .app-nav. Die Checkbox ist bei jedem Seitenaufruf frisch
unchecked (serverseitig gerendert, kein Zustand über Navigation
hinweg nötig). */}}
{{define "nav"}}
<nav>
<header class="topbar">
<a class="brand" href="/">Deklarix</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen">Menü</label>
<nav class="app-nav">
<a href="/">Start</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</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">
<form method="post" action="/logout">
<button type="submit">Abmelden</button>
</form>
</nav>
</header>
{{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-

View File

@@ -0,0 +1,58 @@
{{define "mandant-werkzeuge"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Werkzeugkatalog</h1>
<h2>Zentraler Katalog</h2>
<p class="hinweis">
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}}
<h2>Eigene Werkzeuge</h2>
<p class="hinweis">
Zusätzliche Katalogeinträge nur für diesen Mandanten — sichtbar nur
hier, nicht im zentralen Katalog der Plattform.
</p>
{{if not .EigeneWerkzeuge}}
<p class="hinweis">Noch keine eigenen Werkzeuge.</p>
{{else}}
<ul class="beitraege-liste">
{{range .EigeneWerkzeuge}}
<li>
<a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}} · {{.Anbieter}}</a>
</li>
{{end}}
</ul>
{{end}}
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -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" class="entfernen">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>

View File

@@ -13,6 +13,7 @@
{{if not .Eintraege}}
<p class="hinweis">Noch keine Genehmigungen.</p>
{{else}}
<div class="table-scroll">
<table>
<thead>
<tr>
@@ -31,6 +32,7 @@
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
</body>

View File

@@ -0,0 +1,29 @@
{{define "wiedervorlage-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Wiedervorlage</h1>
<p class="hinweis">
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
</p>
{{if not .Eintraege}}
<p class="keine-findings">Nichts zur Wiedervorlage.</p>
{{else}}
<ul class="findings">
{{range .Eintraege}}
<li class="finding finding-mittel">
<strong><a href="/faelle/{{.AntragID}}">{{.Titel}}</a></strong>{{if .Werkzeug}} — {{.Werkzeug}}{{end}}
<ul>
{{range .Gruende}}<li>{{.}}</li>{{end}}
</ul>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,104 @@
// Ebene 3 — Wiedervorlage (Schritt 7 der Baureihenfolge, siehe
// CLAUDE.md). Eine aktive Genehmigung muss erneut geprüft werden, wenn
// entweder ihr Ablaufdatum erreicht/nah ist, oder sich im Katalog eine
// Eigenschaft geändert hat, auf der sie beruhte. Es gibt keine E-Mail-
// Infrastruktur — "Benachrichtigung" ist v1 eine In-App-Liste, kein
// Versand.
package web
import (
"fmt"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
// wiedervorlageSchwelle: eine Genehmigung erscheint schon vor ihrem
// Ablauf in der Wiedervorlage, damit der/die Verantwortliche Zeit zum
// Reagieren hat.
const wiedervorlageSchwelle = 30 * 24 * time.Hour
// werkzeugDiff vergleicht den zum Entscheidungszeitpunkt eingefrorenen
// Werkzeug-Snapshot mit dem aktuellen Katalogstand und benennt jede für
// die Bewertung relevante Eigenschaft, die sich seitdem geändert hat.
func werkzeugDiff(snapshot, current store.Werkzeug) []string {
var diffs []string
if snapshot.AVVVerfuegbar != current.AVVVerfuegbar {
diffs = append(diffs, fmt.Sprintf("AVV verfügbar: %v → %v", snapshot.AVVVerfuegbar, current.AVVVerfuegbar))
}
if snapshot.TrainingStandard != current.TrainingStandard {
diffs = append(diffs, fmt.Sprintf("Training standardmäßig aus: %v → %v", snapshot.TrainingStandard, current.TrainingStandard))
}
if snapshot.Verarbeitungsort != current.Verarbeitungsort {
diffs = append(diffs, fmt.Sprintf("Verarbeitungsort: %s → %s", snapshot.Verarbeitungsort, current.Verarbeitungsort))
}
return diffs
}
type wiedervorlageItem struct {
AntragID string
Titel string
Werkzeug string
Gruende []string
}
type wiedervorlageListeData struct {
Title string
Nav navData
Eintraege []wiedervorlageItem
}
// handleWiedervorlageListe zeigt alle aktiven Genehmigungen des
// Mandanten, die erneut geprüft werden sollten — abgelaufen/bald
// ablaufend oder mit einem seither im Katalog geänderten Werkzeug.
// Genehmigungen ohne Grund zur Wiedervorlage werden nicht gezeigt.
func (s *Server) handleWiedervorlageListe(w http.ResponseWriter, r *http.Request) {
entscheidungen, err := s.store.ListAktiveGenehmigungenForAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Genehmigungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
var data wiedervorlageListeData
data.Title, data.Nav = "Wiedervorlage", navFor(r)
for _, e := range entscheidungen {
var gruende []string
if e.GueltigBis != nil {
if time.Now().After(*e.GueltigBis) {
gruende = append(gruende, "Genehmigung ist abgelaufen (seit "+e.GueltigBis.Format("02.01.2006")+")")
} else if time.Until(*e.GueltigBis) < wiedervorlageSchwelle {
gruende = append(gruende, "Genehmigung läuft am "+e.GueltigBis.Format("02.01.2006")+" ab")
}
}
werkzeugName := ""
if e.WerkzeugID != nil {
current, err := s.store.GetWerkzeug(r.Context(), *e.WerkzeugID)
if err != nil {
gruende = append(gruende, "Werkzeug wurde aus dem Katalog entfernt")
} else {
werkzeugName = current.Name
if e.WerkzeugSnapshot != nil {
gruende = append(gruende, werkzeugDiff(*e.WerkzeugSnapshot, current)...)
}
}
}
if len(gruende) == 0 {
continue
}
antrag, err := s.store.GetAntrag(r.Context(), e.AntragID)
if err != nil {
continue
}
data.Eintraege = append(data.Eintraege, wiedervorlageItem{
AntragID: antrag.ID, Titel: antrag.Titel, Werkzeug: werkzeugName, Gruende: gruende,
})
}
if err := s.templates.ExecuteTemplate(w, "wiedervorlage-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,95 @@
package web_test
import (
"net/http"
"strings"
"testing"
"time"
)
func TestWiedervorlageZeigtAbgelaufeneGenehmigung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID := genehmigeFall(t, fs, s)
var antragID string
for id, a := range fs.antraege {
if a.AccountID == accountID {
antragID = id
}
}
es := fs.entscheidungen[antragID]
abgelaufen := time.Now().Add(-24 * time.Hour)
es[len(es)-1].GueltigBis = &abgelaufen
fs.entscheidungen[antragID] = es
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "abgelaufen") {
t.Errorf("expected the abgelaufene Genehmigung to appear, got: %s", resp.Body.String())
}
}
func TestWiedervorlageZeigtGeaenderteWerkzeugeigenschaft(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID := genehmigeFall(t, fs, s)
wz := fs.werkzeuge["werkzeug-ok"]
wz.AVVVerfuegbar = false // Katalog hat sich seit der Genehmigung geändert
fs.werkzeuge["werkzeug-ok"] = wz
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "AVV verfügbar") {
t.Errorf("expected the changed AVV property to be flagged, got: %s", resp.Body.String())
}
}
func TestWiedervorlageZeigtEntferntesWerkzeug(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID := genehmigeFall(t, fs, s)
delete(fs.werkzeuge, "werkzeug-ok")
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "aus dem Katalog entfernt") {
t.Errorf("expected the removed werkzeug to be flagged, got: %s", resp.Body.String())
}
}
func TestWiedervorlageIstLeerOhneAenderung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
accountID := genehmigeFall(t, fs, s)
prueferCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
resp := getWithCookie(t, s, prueferCookie, "/wiedervorlage")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Nichts zur Wiedervorlage") {
t.Errorf("expected no entries when nothing changed, got: %s", resp.Body.String())
}
}
func TestMitarbeiterCannotAccessWiedervorlage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/wiedervorlage")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}