Compare commits

...

3 Commits

Author SHA1 Message Date
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
26 changed files with 1416 additions and 78 deletions

128
CLAUDE.md
View File

@@ -72,20 +72,46 @@ ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche. je Handler), nicht als grob unterschiedene Seitenbereiche.
**Ebene 4 — Nutzerverwaltung ist umgesetzt** (`internal/web/admin_handlers.go`, **Ebene 4 ist inzwischen zu großen Teilen umgesetzt** (Anspruch: alles,
Middleware `requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins was im Datenmodell existiert, muss über das Frontend steuerbar sein,
des eigenen Mandanten, `GET/POST /verwaltung/nutzer/neu` legt einen nicht nur einsehbar — Deklarix soll ein buchbarer Service werden, ein
weiteren Login mit einer der vier Mandanten-Rollen an (`mitarbeiter`, reiner Anzeige-Screen reicht nicht):
`verantwortlicher`, `pruefer`, `admin``betreiber` kann kein
Mandanten-Admin vergeben, das ist Ebene 5). Es gibt noch **keine** - **Nutzerverwaltung** (`internal/web/admin_handlers.go`, Middleware
Einladungsmail — der Admin setzt das Initialpasswort direkt im `requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins des
Formular und gibt es auf einem anderen Weg weiter (konsistent mit dem eigenen Mandanten mit Status (aktiv/deaktiviert), `GET/POST
Onboarding-Stand unten: Einladungslink/CSV-Import/SSO sind noch nicht /verwaltung/nutzer/neu` legt einen weiteren Login mit einer der vier
gebaut). Das schließt die Lücke, dass Ebene 3 (Fachebene) bisher nur Mandanten-Rollen an (`mitarbeiter`, `verantwortlicher`, `pruefer`,
über einen manuellen SQL-Insert nutzbar war, weil die Firma- `admin``betreiber` kann kein Mandanten-Admin vergeben, das ist
Registrierung ausschließlich einen `admin`-Nutzer erzeugt. Abteilungen- Ebene 5). Es gibt noch **keine** Einladungsmail — der Admin setzt das
Verwaltung, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen und Initialpasswort direkt im Formular. Nutzer werden **nicht gelöscht**
Rechnungsdaten (Ebene 4 laut Tabelle oben) sind weiterhin nicht gebaut. (`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 **Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
@@ -220,7 +246,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 **Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen — (Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
@@ -251,9 +277,22 @@ erzeugt zusätzlich einen `audit_log`-Eintrag (Action
`antrag_entschieden`). `antrag_entschieden`).
Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
`hochrisiko` 6 — `gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):** `hochrisiko` 6 — `gueltigkeitFuer`).
die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
der eine aktive Genehmigung beruht (Wiedervorlage). **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 **Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
erzeugt automatisch einen `registereintrag` (append-only, Migration erzeugt automatisch einen `registereintrag` (append-only, Migration
@@ -293,6 +332,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 ## Projektstruktur
``` ```
@@ -307,7 +389,10 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
│ └── web/ # Handler, Templates │ └── web/ # Handler, Templates
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen) ├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/ ├── 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/ ├── packaging/
│ └── DEBIAN/ │ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template │ └── control.tmpl # .deb Package-Control-Template
@@ -412,7 +497,10 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte
`go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor` `go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor`
für deutsche Umlaute.) 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 Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was 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 { if byID.Email != "team@example.com" || byID.AccountID != accID {
t.Fatalf("GetUser = %+v, unerwartete Werte", byID) 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) { func TestGetUserByEmailNotFound(t *testing.T) {

View File

@@ -109,3 +109,38 @@ func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID str
} }
return e, nil 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") 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). // Login: falsche E-Mail vs. Datenbankfehler).
var ErrNotFound = errors.New("store: nicht gefunden") 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 { type User struct {
ID string ID string
AccountID string AccountID string
Email string Email string
PasswordHash string PasswordHash string
Role string Role string
Active bool
CreatedAt time.Time 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. // CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store // passwordHash muss bereits gehasht sein (siehe internal/auth) — store
// speichert nur, es hasht nicht selbst. // speichert nur, es hasht nicht selbst.
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) { func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
var u User row := s.Pool.QueryRow(ctx, `
err := s.Pool.QueryRow(ctx, `
INSERT INTO app_user (account_id, email, password_hash, role) INSERT INTO app_user (account_id, email, password_hash, role)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING id, account_id, email, password_hash, role, created_at RETURNING `+userColumns,
`, accountID, email, passwordHash, role).Scan( accountID, email, passwordHash, role,
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
) )
u, err := scanUser(row)
if err != nil { if err != nil {
return User{}, fmt.Errorf("store: create user: %w", err) 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. // GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler). // Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) { func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
var u User row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
err := s.Pool.QueryRow(ctx, ` u, err := scanUser(row)
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)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound 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. // GetUser liest einen Nutzer anhand seiner ID.
func (s *Store) GetUser(ctx context.Context, id string) (User, error) { func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
var u User row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
err := s.Pool.QueryRow(ctx, ` u, err := scanUser(row)
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)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound return User{}, ErrNotFound
} }
@@ -80,8 +87,7 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
// Admin-Bereich (Account-Detailansicht). // Admin-Bereich (Account-Detailansicht).
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) { func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
rows, err := s.Pool.Query(ctx, ` rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, email, password_hash, role, created_at SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
FROM app_user WHERE account_id = $1 ORDER BY created_at
`, accountID) `, accountID)
if err != nil { if err != nil {
return nil, fmt.Errorf("store: list users for account: %w", err) 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 var out []User
for rows.Next() { for rows.Next() {
var u User u, err := scanUser(rows)
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil { if err != nil {
return nil, fmt.Errorf("store: scan user: %w", err) return nil, fmt.Errorf("store: scan user: %w", err)
} }
out = append(out, u) out = append(out, u)
@@ -101,3 +107,18 @@ func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]Us
} }
return out, nil 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 // Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für
// Mandanten, siehe CLAUDE.md, Fünf Ebenen. Aktuell nur Nutzerverwaltung // GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren,
// (Abteilungen, Anmeldeverfahren, Werkzeug-Freigaben, Rechnungsdaten // Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin
// sind spätere Ausbaustufen). Es gibt noch keinen Einladungslink/CSV- // spätere Ausbaustufen. Es gibt noch keinen Einladungslink/CSV-Import/
// Import/SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier // SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier direkt ein
// direkt ein Initialpasswort, wie schon bei der Firma-Registrierung. // Initialpasswort, wie schon bei der Firma-Registrierung.
package web package web
import ( import (
@@ -27,8 +27,11 @@ func istMandantenRolle(role string) bool {
} }
type nutzerListItem struct { type nutzerListItem struct {
Email string ID string
Role string Email string
Role string
Active bool
IstSelbst bool
} }
type nutzerListeData struct { type nutzerListeData struct {
@@ -39,20 +42,61 @@ type nutzerListeData struct {
// handleNutzerListe zeigt alle Logins des eigenen Mandanten. // handleNutzerListe zeigt alle Logins des eigenen Mandanten.
func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)} data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
for _, u := range users { 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 { if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) 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 { type nutzerNeuData struct {
Title string Title string
Nav navData Nav navData
@@ -106,3 +150,69 @@ func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther) 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"}) s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
return 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 { if err := s.startSession(w, r, user.ID); err != nil {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"}) s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})

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

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) user, err := s.store.GetUser(r.Context(), sess.UserID)
if err != nil { if err != nil || !user.Active {
return store.User{}, false return store.User{}, false
} }
return user, true 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) GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error) GetUser(ctx context.Context, id string) (store.User, error)
ListUsersForAccount(ctx context.Context, accountID 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) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error) GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error DeleteSession(ctx context.Context, token string) error
@@ -42,6 +43,8 @@ type Store interface {
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, 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) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
GetAntrag(ctx context.Context, id 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) 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) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
ListZentraleWerkzeuge(ctx context.Context) ([]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) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (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) 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) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (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) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
@@ -117,9 +124,18 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe)) mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV)) mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF)) 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", s.requireAdmin(s.handleNutzerListe))
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm)) mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) 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", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) 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 bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
} }
func newFakeStore() *fakeStore { func newFakeStore() *fakeStore {
@@ -51,6 +52,7 @@ func newFakeStore() *fakeStore {
bewertungen: map[string][]store.Bewertung{}, bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{}, entscheidungen: map[string][]store.Entscheidung{},
registereintraege: map[string][]store.Registereintrag{}, 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{ u := store.User{
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash, 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.users[u.ID] = u
f.usersByEmail[email] = u.ID f.usersByEmail[email] = u.ID
@@ -122,6 +124,18 @@ func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error)
return u, nil 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) { func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -202,6 +216,28 @@ func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilun
return store.Abteilung{}, store.ErrNotFound 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) { func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -296,6 +332,33 @@ func (f *fakeStore) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug
return out, nil 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) { func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -400,6 +463,26 @@ func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID
return es[len(es)-1], nil 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) { func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()

View File

@@ -1,9 +1,10 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens /* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt. Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width) ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
erweitert für größere Bildschirme. */ Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
@import url('/static/inter.css'); @import url('/static/inter.css');
@@ -14,6 +15,7 @@
--radius-lg: 10px; --radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --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: 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-bg: #f8fafc;
--color-text: #334155; --color-text: #334155;
@@ -27,6 +29,12 @@
--color-mittel-bg: #fffbeb; --color-mittel-bg: #fffbeb;
--color-niedrig: #166534; --color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4; --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; 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; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
gap: 16px;
padding: 12px 16px; padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--color-border);
} }
nav a { .brand {
color: var(--color-muted); 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-size: 0.875rem;
font-weight: 500; 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; text-decoration: none;
} }
nav a:hover { .app-nav a:hover {
color: var(--color-text); 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 /* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
@@ -275,7 +369,12 @@ nav button {
gap: 8px; 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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@@ -288,6 +387,15 @@ nav button {
text-decoration: none; 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 { .beteiligte {
list-style: none; list-style: none;
margin: 0 0 16px; margin: 0 0 16px;
@@ -424,7 +532,45 @@ button.entfernen {
display: block; 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. */ Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) { @media (min-width: 640px) {
.page { .page {
@@ -435,3 +581,74 @@ button.entfernen {
font-size: 1.75rem; 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

@@ -56,7 +56,7 @@
</form> </form>
{{if .ID}} {{if .ID}}
<form method="post" action="/betreiber/werkzeuge/{{.ID}}/loeschen" onsubmit="return confirm('Werkzeug wirklich löschen?')"> <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> </form>
{{end}} {{end}}
</div> </div>

View File

@@ -6,18 +6,36 @@
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
{{end}} {{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"}} {{define "nav"}}
<nav> <header class="topbar">
<a href="/">Start</a> <a class="brand" href="/">Deklarix</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}} <label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen">Menü</label>
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}} <nav class="app-nav">
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}} <a href="/">Start</a>
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}} {{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
<form method="post" action="/logout" style="display:inline"> {{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
<button type="submit">Abmelden</button> {{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
</form> {{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
</nav> {{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">
<button type="submit">Abmelden</button>
</form>
</nav>
</header>
{{end}} {{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO- {{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-

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

View File

@@ -7,7 +7,22 @@
<h1>Nutzerverwaltung</h1> <h1>Nutzerverwaltung</h1>
<ul class="beitraege-liste"> <ul class="beitraege-liste">
{{range .Nutzer}} {{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}} {{end}}
</ul> </ul>
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p> <p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p>

View File

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