Compare commits
3 Commits
5f6502bb41
...
v0.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b91a58ccba | ||
|
|
eb99891e38 | ||
|
|
36af1bf288 |
93
CLAUDE.md
93
CLAUDE.md
@@ -98,20 +98,26 @@ reiner Anzeige-Screen reicht nicht):
|
||||
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`,
|
||||
- **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`).
|
||||
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, 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).
|
||||
**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
|
||||
@@ -319,12 +325,19 @@ leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
|
||||
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
|
||||
Ebene 5/Betreiber-Pflege gebaut ist).
|
||||
|
||||
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink
|
||||
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
|
||||
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
|
||||
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
|
||||
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
|
||||
Registrierung.
|
||||
**Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~ —
|
||||
**erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
|
||||
Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
|
||||
Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
|
||||
jederzeit erneuerbar [macht den alten Link sofort ungültig].
|
||||
`GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
|
||||
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
||||
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
||||
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
||||
Einladung.), (2) CSV-Import, (3) SSO (Entra ID/Google Workspace),
|
||||
(4) Antrag ganz ohne vorheriges Konto über Mandantenlink + E-Mail-
|
||||
Bestätigungscode (Konto entsteht nebenbei). (2)-(4) sind weiterhin
|
||||
nicht gebaut.
|
||||
|
||||
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
|
||||
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
||||
@@ -332,6 +345,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
|
||||
|
||||
```
|
||||
@@ -346,7 +402,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
|
||||
|
||||
@@ -10,20 +10,35 @@ import (
|
||||
)
|
||||
|
||||
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
|
||||
// nutzt). Jeder Antrag gehört genau einem Account.
|
||||
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
|
||||
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
|
||||
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
|
||||
type Account struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
Name string
|
||||
EinladungToken string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an.
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
const accountColumns = `id, name, einladung_token, created_at`
|
||||
|
||||
func scanAccount(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO account (name) VALUES ($1)
|
||||
RETURNING id, name, created_at
|
||||
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
RETURNING `+accountColumns,
|
||||
name,
|
||||
)
|
||||
a, err := scanAccount(row)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||
}
|
||||
@@ -32,10 +47,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
||||
|
||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, created_at FROM account WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
@@ -45,12 +58,40 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAccountByEinladungToken liest den Mandanten zu einem
|
||||
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
|
||||
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
||||
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
|
||||
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
||||
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: regenerate einladung token: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||
// Admin-Bereich (Accounts-Verwaltung).
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, created_at FROM account ORDER BY created_at DESC
|
||||
`)
|
||||
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
@@ -58,8 +99,8 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
|
||||
a, err := scanAccount(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
|
||||
@@ -25,6 +25,56 @@ func TestAccountCRUD(t *testing.T) {
|
||||
if got.Name != "Beispiel Agentur GmbH" {
|
||||
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
||||
}
|
||||
if got.EinladungToken == "" {
|
||||
t.Error("expected a newly created account to have a non-empty EinladungToken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken: %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken returned a different account")
|
||||
}
|
||||
|
||||
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
alterToken := acc.EinladungToken
|
||||
|
||||
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
|
||||
t.Fatalf("RegenerateEinladungToken: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("expected the old token to be invalid, got err = %v", err)
|
||||
}
|
||||
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE account DROP COLUMN einladung_token;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
|
||||
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
|
||||
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
|
||||
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
|
||||
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
|
||||
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
|
||||
-- dass store.CreateAccount etwas ändern muss.
|
||||
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;
|
||||
@@ -31,8 +31,9 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
|
||||
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
|
||||
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
|
||||
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
|
||||
// (spätere Ausbaustufe), nicht über dieses Formular.
|
||||
// Mitarbeiter über dieses Formular — die kommen über den
|
||||
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
|
||||
// Import/Mandantenlink (weiterhin nicht gebaut).
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
140
internal/web/einladung_handlers.go
Normal file
140
internal/web/einladung_handlers.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
|
||||
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
|
||||
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
|
||||
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
|
||||
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
|
||||
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
|
||||
// Abschnitt A), nicht hier.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type einladungFormData struct {
|
||||
Title string
|
||||
Firmenname string
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
|
||||
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
|
||||
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
|
||||
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
|
||||
// existiert hat oder nur nicht mehr gültig ist.
|
||||
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||
if err != nil {
|
||||
s.renderEinladungError(w)
|
||||
return
|
||||
}
|
||||
s.renderEinladungForm(w, acc.Name, "")
|
||||
}
|
||||
|
||||
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
|
||||
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderEinladungError(w http.ResponseWriter) {
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
|
||||
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
|
||||
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
|
||||
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
|
||||
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||
if err != nil {
|
||||
s.renderEinladungError(w)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if email == "" || password == "" {
|
||||
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
|
||||
if err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type einladungVerwaltungData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Link string
|
||||
}
|
||||
|
||||
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
|
||||
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
|
||||
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
|
||||
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
|
||||
// X-Forwarded-Proto berücksichtigen.
|
||||
func einladungBasisURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
|
||||
// Mandanten zum Kopieren.
|
||||
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := einladungVerwaltungData{
|
||||
Title: "Einladungslink", Nav: navFor(r),
|
||||
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
|
||||
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
|
||||
// außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
|
||||
newToken, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
|
||||
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
|
||||
}
|
||||
144
internal/web/einladung_handlers_test.go
Normal file
144
internal/web/einladung_handlers_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEinladungFormZeigtFirmenname(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
|
||||
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if len(resp.Result().Cookies()) == 0 {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
|
||||
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
|
||||
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
|
||||
}
|
||||
|
||||
cookie := resp.Result().Cookies()[0]
|
||||
protected := getWithCookie(t, s, cookie, "/antraege")
|
||||
if protected.Code != http.StatusOK {
|
||||
t.Fatalf("expected the new session to work, status = %d", protected.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||
}
|
||||
if len(fs.users) != 0 {
|
||||
t.Error("expected no user to be created for an invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtEinladungslink(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "/einladung/") {
|
||||
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannEinladungslinkErneuern(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)
|
||||
}
|
||||
altesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
neuesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||
if neuesToken == altesToken {
|
||||
t.Fatal("expected the token to change")
|
||||
}
|
||||
|
||||
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
|
||||
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
|
||||
}
|
||||
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
|
||||
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessEinladungsverwaltung(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/einladung")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
// 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).
|
||||
// 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"
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mandantWerkzeugItem struct {
|
||||
ID string
|
||||
@@ -13,14 +20,23 @@ type mandantWerkzeugItem struct {
|
||||
Gesperrt bool
|
||||
}
|
||||
|
||||
type eigenesWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
}
|
||||
|
||||
type mandantWerkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []mandantWerkzeugItem
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []mandantWerkzeugItem
|
||||
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
|
||||
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag.
|
||||
// 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())
|
||||
@@ -33,6 +49,11 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
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
|
||||
@@ -44,11 +65,124 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
|
||||
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
|
||||
|
||||
@@ -30,6 +30,8 @@ var staticFS embed.FS
|
||||
type Store interface {
|
||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||
ListAccounts(ctx context.Context) ([]store.Account, error)
|
||||
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
||||
@@ -110,6 +112,8 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
||||
mux.HandleFunc("POST /register", s.handleRegister)
|
||||
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
|
||||
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
@@ -130,12 +134,19 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
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/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
||||
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))
|
||||
|
||||
@@ -64,11 +64,35 @@ func (f *fakeStore) newID() string {
|
||||
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
|
||||
id := f.newID()
|
||||
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
|
||||
f.accounts[acc.ID] = acc
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, acc := range f.accounts {
|
||||
if acc.EinladungToken == token {
|
||||
return acc, nil
|
||||
}
|
||||
}
|
||||
return store.Account{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc, ok := f.accounts[accountID]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
acc.EinladungToken = newToken
|
||||
f.accounts[accountID] = acc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<li>
|
||||
{{.Name}}
|
||||
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
|
||||
<button type="submit">Löschen</button>
|
||||
<button type="submit" class="entfernen">Löschen</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/betreiber/werkzeuge">← Werkzeugkatalog</a></p>
|
||||
<p><a href="{{.ActionBase}}">← 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>
|
||||
|
||||
22
internal/web/templates/einladung.html
Normal file
22
internal/web/templates/einladung.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{{define "einladung"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Einladung annehmen</h1>
|
||||
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
<form method="post">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" minlength="8" required>
|
||||
|
||||
<button type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
12
internal/web/templates/einladung_ungueltig.html
Normal file
12
internal/web/templates/einladung_ungueltig.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{{define "einladung-ungueltig"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Einladung</h1>
|
||||
<p class="fehler">Dieser Einladungslink ist ungültig oder wurde erneuert. Bitte einen aktuellen Link anfragen.</p>
|
||||
<p><a href="/login">Zur Anmeldung</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
20
internal/web/templates/einladung_verwaltung.html
Normal file
20
internal/web/templates/einladung_verwaltung.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{{define "einladung-verwaltung"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Einladungslink</h1>
|
||||
<p class="hinweis">
|
||||
Ein Sammellink für alle Mitarbeitenden — jede Person, die den Link
|
||||
öffnet, kann sich selbst mit eigenem Passwort anmelden (Rolle
|
||||
"mitarbeiter"). Kein Ablaufdatum, aber jederzeit erneuerbar.
|
||||
</p>
|
||||
<p><input type="text" readonly value="{{.Link}}" onclick="this.select()"></p>
|
||||
<form method="post" action="/verwaltung/einladung/erneuern" onsubmit="return confirm('Neuen Link erzeugen? Der alte Link wird dann sofort ungültig.')">
|
||||
<button type="submit" class="entfernen">Link erneuern</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -6,21 +6,37 @@
|
||||
<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>
|
||||
<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">
|
||||
<button type="submit">Abmelden</button>
|
||||
</form>
|
||||
</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/einladung">Einladungslink</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}}
|
||||
|
||||
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Werkzeugkatalog</h1>
|
||||
|
||||
<h2>Zentraler Katalog</h2>
|
||||
<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.
|
||||
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>
|
||||
@@ -32,6 +34,24 @@
|
||||
{{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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<span class="status status-published">aktiv</span>
|
||||
{{if not .IstSelbst}}
|
||||
<form method="post" action="/verwaltung/nutzer/{{.ID}}/deaktivieren" style="display:inline">
|
||||
<button type="submit">Deaktivieren</button>
|
||||
<button type="submit" class="entfernen">Deaktivieren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user