Compare commits

...

3 Commits

Author SHA1 Message Date
noroot
dbeaa45644 feat: Ampel-Kennzeichnung für Prüfalter im Werkzeugkatalog, Scroll-Fade für breite Tabellen
Katalogtabellen zeigten bisher nur "nicht aktuell geprüft" als
Binärflag; jetzt eine dreistufige Ampel (aktuell/bald fällig/
abgelaufen) mit denselben Farbtoken wie die KI-VO-Risikoampel.
Zusätzlich: .table-scroll bekommt einen rechten Fade-Rand, damit eine
am Fensterrand abgeschnittene breite Tabelle als "hier geht's mit
Scrollen weiter" erkennbar ist statt wie ein Rendering-Fehler
auszusehen.
2026-08-31 12:28:57 +02:00
noroot
4358dbaa0d feat: Werkzeugkatalog als Tabelle, Aufbewahrung nullable, kontrolliertes Zweck-Vokabular, Subprozessoren
Katalog (zentral + mandantenseitig) zeigt jetzt eine mehrspaltige
Tabelle statt einer Liste. aufbewahrung_tage ist nullable (NULL =
vom Anbieter nicht beziffert, unterscheidbar von echter 0-Tage-
Zusicherung). geeignete_zwecke ist Checkbox-Auswahl aus den sechs
Fragebogen-Zweck-Kategorien statt Freitext. Neues Feld
subprozessoren macht Unterauftragsverarbeiter durchsuchbar statt nur
Freitext in Einschränkungen. 180-Tage-Frische-Markierung in beiden
Katalogansichten ergänzt.
2026-08-31 12:08:05 +02:00
noroot
7397f70068 feat: Firmen-CRUD für den Betreiber (anlegen, umbenennen)
Schließt eine echte Lücke: der Betreiber-Bereich konnte Accounts
bisher nur lesend anzeigen, eine neue Firma entstand ausschließlich
über die öffentliche Selbstregistrierung. GET/POST
/betreiber/accounts/neu legt jetzt eine Firma samt erstem admin-Login
direkt vom Betreiber aus an (z. B. für vertriebsunterstütztes
Onboarding oder Testkonten) — erzeugt einen audit_log-Eintrag. POST
/betreiber/accounts/{id}/umbenennen korrigiert den Firmennamen
(store.UpdateAccount). Bewusst kein Löschen: ein Hard-Delete würde
gegen die Fremdschlüssel aus antrag/app_user/audit_log laufen und
Historie zerstören — dasselbe Prinzip wie bei Nutzern (deaktivieren
statt löschen), ein Sperren/Deaktivieren für Accounts fehlt aber noch
und hängt an der noch nicht getroffenen Abrechnungsarchitektur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:39:01 +02:00
21 changed files with 699 additions and 100 deletions

View File

@@ -129,15 +129,33 @@ zeigt die Nav einen auffälligen Banner ("Support-Zugriff durch ...",
siehe `currentImpersonator` in `middleware.go`), damit nie unklar ist,
im Kontext eines fremden Kontos zu handeln.
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes
Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am
2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme
10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die
eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus-
Zugangsdaten), Account-Verwaltung durch den Betreiber jenseits des
Support-Logins (Bearbeiten/Sperren eines Accounts selbst hängt weiter
an der Abrechnungs-/Freischaltungs-Architektur, siehe Offene Punkte:
`account.verified` wurde beim Produktwechsel sogar entfernt).
**Firmen-CRUD für den Betreiber ist umgesetzt:** `GET/POST
/betreiber/accounts/neu` legt eine Firma samt erstem `admin`-Login an
(dasselbe Ergebnis wie die öffentliche Registrierung, nur vom Betreiber
ausgelöst — z. B. für vertriebsunterstütztes Onboarding oder Testkonten),
`POST /betreiber/accounts/{id}/umbenennen` korrigiert den Firmennamen
(`store.UpdateAccount`). Jede Firmenanlage erzeugt einen
`audit_log`-Eintrag. Bewusst **kein** Löschen — ein Hard-Delete würde
gegen die Fremdschlüssel aus `antrag`/`app_user`/`audit_log` laufen und
Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt
löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert
noch nicht, siehe unten).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, das
Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche
Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes,
externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**.
Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte
Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum),
der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt
aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen
Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst
(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage
Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der
Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts
durch den Betreiber hängt weiter an dieser (jetzt kleineren)
Subskriptions-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
@@ -235,9 +253,56 @@ zentralen Katalog zu verändern.
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
nicht in der UI umgesetzt, siehe Offene Punkte).
eine Aussage, die nicht nachweisbar ist. Beide Katalogansichten zeigen
eine dreistufige Ampel zum Prüfalter (`pruefStatus()` in
`betreiber_werkzeug_handlers.go`, CSS-Klassen `status-niedrig`/
`-mittel`/`-hoch`, dieselben Farbtoken wie die KI-VO-Risikoampel):
**aktuell** (grün, < 150 Tage), **bald fällig** (gelb, 150180 Tage —
dasselbe 30-Tage-Vorwarnfenster wie bei der Wiedervorlage), **abgelaufen**
(rot, > 180 Tage).
`aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet
"vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte
Zusicherung: sofortige Löschung", z. B. DeepL) unterschieden. Vorher
war die Spalte `NOT NULL DEFAULT 0`, wodurch beide Fälle identisch
aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt
"0 Tage".
`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes
Vokabular statt Freitext: das Formular zeigt Checkboxen für genau die
sechs Zweck-Kategorien aus dem Fragebogen (Textgenerierung,
Übersetzung, Code, Bild, Transkription, Recherche — `gueltigeZwecke` in
`betreiber_werkzeug_handlers.go`), serverseitig validiert
(`istGueltigerZweck`). Grund: ein künftiger automatischer Zweckabgleich
(Bewertungslogik Schritt 3, weiterhin nicht gebaut) würde an
inkonsistentem Freitext ("Text" statt "Textgenerierung") scheitern.
Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht
Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot,
AWS bei DeepL) als eigenes, durchsuchbares Feld sichtbar statt nur als
Freitext in `einschraenkungen` — das ist oft der Grund, warum ein
scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt.
Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die
mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog
als Tabelle (Name, Anbieter, Verarbeitungsort, AVV, Training aus,
Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt
als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar,
der Rest war erst nach Klick auf den Detaileintrag zu sehen.
Der zentrale Katalog ist aktuell mit 10 recherchierten Einträgen befüllt
(ChatGPT Enterprise, Microsoft 365 Copilot, Claude for Work, GitHub
Copilot Business, DeepL Pro/API, Google Gemini für Google Workspace,
Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly Enterprise,
Otter.ai Business) — deckt damit erstmals alle sechs Zweck-Kategorien
ab (vorher fehlten Bild und Transkription vollständig, Recherche hatte
kein dediziertes Werkzeug). Jede Zusicherung ist mit einer echten,
offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten —
zwei Einträge (Adobe Firefly: Trainingsausschluss ist nicht Standard;
Otter.ai: Training auf de-identifizierten Aufnahmen, laufende
Rechtsstreitigkeiten) sind bewusst mit kritischen Einschränkungen
versehen statt schöngefärbt, das ist die vorgesehene Funktion des
Katalogs.
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in

View File

@@ -45,6 +45,20 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
return a, nil
}
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
// Betreiber-gestützten Anlage korrigieren).
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: update account: %w", err)
}
return a, nil
}
// GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)

View File

@@ -30,6 +30,39 @@ func TestAccountCRUD(t *testing.T) {
}
}
func TestUpdateAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
if err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
if updated.Name != "Neuer Name GmbH" {
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Neuer Name GmbH" {
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
}
}
func TestUpdateAccountNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()

View File

@@ -0,0 +1,5 @@
ALTER TABLE werkzeug DROP COLUMN subprozessoren;
UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL;

View File

@@ -0,0 +1,12 @@
-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig:
-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung),
-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das
-- sauber: "unbekannt" statt "angeblich sofort gelöscht".
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT;
-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei
-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung
-- eines Werkzeugs materiell — bisher nur als Freitext in
-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld.
ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}';

View File

@@ -25,8 +25,9 @@ type Werkzeug struct {
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
@@ -36,7 +37,7 @@ type Werkzeug struct {
}
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
// Create/Update — bei 14 Feldern lesbarer als eine positionale
// Create/Update — bei 15 Feldern lesbarer als eine positionale
// Parameterliste.
type WerkzeugInput struct {
AccountID *string
@@ -47,8 +48,9 @@ type WerkzeugInput struct {
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
AufbewahrungTage *int
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
@@ -56,8 +58,8 @@ type WerkzeugInput struct {
}
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
func scanWerkzeug(row interface {
Scan(dest ...any) error
@@ -65,8 +67,8 @@ func scanWerkzeug(row interface {
var w Werkzeug
err := row.Scan(
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
&w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
)
return w, err
}
@@ -86,6 +88,9 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) {
if in.Einschraenkungen == nil {
in.Einschraenkungen = []string{}
}
if in.Subprozessoren == nil {
in.Subprozessoren = []string{}
}
}
// CreateWerkzeug legt einen Katalogeintrag an.
@@ -94,12 +99,12 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug,
row := s.Pool.QueryRow(ctx, `
INSERT INTO werkzeug (
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING `+werkzeugColumns,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)
@@ -120,12 +125,12 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput)
UPDATE werkzeug SET
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
letzte_pruefung = $13, quelle = $14, updated_at = now()
zertifizierungen = $10, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13,
letzte_pruefung = $14, quelle = $15, updated_at = now()
WHERE id = $1
RETURNING `+werkzeugColumns,
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
)
w, err := scanWerkzeug(row)

View File

@@ -9,6 +9,8 @@ import (
"github.com/netcell-it/deklarix/internal/store"
)
func intPtr(v int) *int { return &v }
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
return store.WerkzeugInput{
AccountID: accountID,
@@ -19,8 +21,9 @@ func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
AVVURL: "https://beispiel.example/avv",
TrainingOptOut: true,
TrainingStandard: true,
AufbewahrungTage: 30,
AufbewahrungTage: intPtr(30),
Zertifizierungen: []string{"ISO 27001"},
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
GeeigneteZwecke: []string{"Textgenerierung"},
Einschraenkungen: nil,
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
@@ -42,6 +45,24 @@ func TestWerkzeugCRUD(t *testing.T) {
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
}
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
}
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
}
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
in.AufbewahrungTage = nil
return in
}())
if err != nil {
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
}
if unbekannt.AufbewahrungTage != nil {
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
}
got, err := s.GetWerkzeug(ctx, w.ID)
if err != nil {
@@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) {
}
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
updateInput.AufbewahrungTage = 14
updateInput.AufbewahrungTage = intPtr(14)
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
if err != nil {
t.Fatalf("UpdateWerkzeug: %v", err)
}
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage == nil || *updated.AufbewahrungTage != 14 {
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
}

View File

@@ -289,9 +289,13 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string,
}
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
for i, w := range werkzeuge {
aufbewahrungTage := 0
if w.AufbewahrungTage != nil {
aufbewahrungTage = *w.AufbewahrungTage
}
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
}}
}
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)

View File

@@ -0,0 +1,98 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user.Role != "admin" {
t.Errorf("Role = %q, want admin", user.Role)
}
acc, err := fs.GetAccount(context.Background(), user.AccountID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if acc.Name != "Neue Firma GmbH" {
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
}
// Der neue Login funktioniert tatsächlich.
loginResp := postForm(t, s, nil, "/login", url.Values{
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
})
if loginResp.Code != http.StatusSeeOther {
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
}
}
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Pflicht") {
t.Errorf("expected a validation error, got: %s", resp.Body.String())
}
}
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
acc, err := fs.CreateAccount(context.Background(), "Alter Name")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
got, err := fs.GetAccount(context.Background(), acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Neuer Name" {
t.Errorf("Name = %q, want Neuer Name", got.Name)
}
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
if !strings.Contains(detail.Body.String(), "Neuer Name") {
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
}
}
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -75,6 +75,98 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
}
}
type betreiberAccountNeuData struct {
Title string
Nav navData
Error string
}
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountName := r.FormValue("account_name")
email := r.FormValue("email")
password := r.FormValue("password")
if accountName == "" || email == "" || password == "" {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
ctx := r.Context()
acc, err := s.store.CreateAccount(ctx, accountName)
if err != nil {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
}
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
// audit_log laufen).
func (s *Server) handleBetreiberAccountRename(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
}
id := r.PathValue("id")
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
}
type betreiberUserView struct {
ID string
Email string

View File

@@ -25,6 +25,23 @@ func istGueltigerVerarbeitungsort(v string) bool {
return false
}
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
// — exakt die sechs Zweck-Kategorien aus dem Fragebogen/der geplanten
// Zweckabgleich-Stufe (siehe CLAUDE.md, Bewertungslogik Schritt 3).
// Vorher war das Feld Freitext; ein Tippfehler ("Text" statt
// "Textgenerierung") hätte den künftigen automatischen Zweckabgleich
// unbemerkt kaputt gemacht.
var gueltigeZwecke = []string{"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche"}
func istGueltigerZweck(v string) bool {
for _, z := range gueltigeZwecke {
if z == v {
return true
}
}
return false
}
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
// führende/folgende Leerzeichen und ohne leere Einträge.
@@ -44,7 +61,27 @@ type werkzeugListItem struct {
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
Zertifizierungen string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS string
}
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
// sofortigen Löschung) verwechselt wird.
func formatAufbewahrung(tage *int) string {
if tage == nil {
return "unbekannt"
}
if *tage == 0 {
return "0 Tage (sofort)"
}
return strconv.Itoa(*tage) + " Tage"
}
type werkzeugListeData struct {
@@ -53,6 +90,34 @@ type werkzeugListeData struct {
Werkzeuge []werkzeugListItem
}
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
// nicht erst wenn die Frist schon gerissen ist.
const (
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
)
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
func pruefStatus(letztePruefung time.Time) (label, css string) {
alter := time.Since(letztePruefung)
switch {
case alter > werkzeugPruefungVeraltetNach:
return "abgelaufen", "status-hoch"
case alter > werkzeugPruefungBaldFaelligNach:
return "bald fällig", "status-mittel"
default:
return "aktuell", "status-niedrig"
}
}
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
@@ -62,9 +127,14 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req
}
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
for _, wz := range werkzeuge {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
@@ -89,17 +159,52 @@ type werkzeugFormData struct {
AVVURL string
TrainingOptOut bool
TrainingStandard bool
AufbewahrungTage int
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
Zertifizierungen string
GeeigneteZwecke string
Subprozessoren string
ZweckeOptionen []string
ZweckeAusgewaehlt map[string]bool
Einschraenkungen string
LetztePruefung string
Quelle string
Error string
}
// zweckeAusgewaehltVon baut die Checkbox-Vorbelegung aus einer bereits
// gespeicherten oder erneut eingereichten Liste von Zwecken.
func zweckeAusgewaehltVon(ausgewaehlt []string) map[string]bool {
m := make(map[string]bool, len(ausgewaehlt))
for _, z := range ausgewaehlt {
m[z] = true
}
return m
}
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
aufbewahrung := ""
if wz.AufbewahrungTage != nil {
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
}
return werkzeugFormData{
Title: title, Nav: navFor(r), ActionBase: actionBase, 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: aufbewahrung,
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(wz.GeeigneteZwecke),
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
Quelle: wz.Quelle,
}
}
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
data := werkzeugFormData{
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -123,7 +228,21 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
if name == "" || anbieter == "" || quelle == "" {
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
}
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
var aufbewahrungTage *int
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
wert, err := strconv.Atoi(rohwert)
if err != nil || wert < 0 {
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
}
aufbewahrungTage = &wert
}
var geeigneteZwecke []string
for _, z := range r.Form["geeignete_zwecke"] {
if istGueltigerZweck(z) {
geeigneteZwecke = append(geeigneteZwecke, z)
}
}
return store.WerkzeugInput{
AccountID: accountID,
@@ -136,7 +255,8 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn
TrainingStandard: r.FormValue("training_standard") == "on",
AufbewahrungTage: aufbewahrungTage,
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
GeeigneteZwecke: geeigneteZwecke,
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
LetztePruefung: letztePruefung,
Quelle: quelle,
@@ -152,7 +272,9 @@ func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg
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",
Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"),
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: zweckeAusgewaehltVon(r.Form["geeignete_zwecke"]),
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
Quelle: r.FormValue("quelle"), Error: errMsg,
}
@@ -192,15 +314,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
data := werkzeugFormData{
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,
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,
}
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

View File

@@ -14,16 +14,26 @@ import (
)
type mandantWerkzeugItem struct {
ID string
Name string
Anbieter string
Gesperrt bool
ID string
Name string
Anbieter string
Verarbeitungsort string
AVVVerfuegbar bool
TrainingStandard bool
Aufbewahrung string
GeeigneteZwecke string
LetztePruefung string
PruefStatusLabel string
PruefStatusCSS string
Gesperrt bool
}
type eigenesWerkzeugItem struct {
ID string
Name string
Anbieter string
ID string
Name string
Anbieter string
Verarbeitungsort string
LetztePruefung string
}
type mandantWerkzeugListeData struct {
@@ -61,15 +71,25 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
for _, wz := range zentral {
label, css := pruefStatus(wz.LetztePruefung)
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID],
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
PruefStatusLabel: label, PruefStatusCSS: css,
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})
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
})
}
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
@@ -77,7 +97,10 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque
}
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
data := werkzeugFormData{
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
Verarbeitungsorte: gueltigeVerarbeitungsorte, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
}
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -121,15 +144,7 @@ func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Re
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,
}
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}

View File

@@ -29,6 +29,7 @@ var staticFS embed.FS
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
CreateAccount(ctx context.Context, name string) (store.Account, error)
UpdateAccount(ctx context.Context, id, 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
@@ -152,7 +153,10 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
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/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))

View File

@@ -70,6 +70,18 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
return acc, nil
}
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
acc.Name = name
f.accounts[id] = acc
return acc, nil
}
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()

View File

@@ -392,6 +392,16 @@ nav button {
color: var(--color-mittel);
}
.status-niedrig {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-hoch {
background: var(--color-hoch-bg);
color: var(--color-hoch);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
@@ -595,11 +605,17 @@ tbody tr:hover {
background: var(--color-bg);
}
/* Ohne dieses Signal sieht eine breite, am Fensterrand abgeschnittene
Tabelle wie ein Rendering-Fehler aus statt wie "hier geht's mit
Scrollen weiter" — der Fade ist bewusst immer sichtbar (kein JS, das
den Scroll-Zustand prüfen könnte), auch am Ende der Tabelle. */
.table-scroll {
overflow-x: auto;
box-shadow: var(--shadow);
border-radius: var(--radius-md);
margin-bottom: 16px;
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
}
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren

View File

@@ -8,6 +8,12 @@
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
<label for="name">Firmenname</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
<button type="submit">Umbenennen</button>
</form>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}

View File

@@ -0,0 +1,29 @@
{{define "betreiber-account-neu"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber/accounts">&larr; Accounts</a></p>
<h1>Firma anlegen</h1>
<p class="hinweis">
Legt eine Firma mit dem ersten Login (Rolle admin) an — dasselbe
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/betreiber/accounts">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
<input type="email" id="email" name="email" required>
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Firma anlegen</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -19,6 +19,7 @@
{{end}}
</ul>
{{end}}
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
</div>
</body>
</html>

View File

@@ -34,14 +34,21 @@
<label><input type="checkbox" name="training_opt_out" {{if .TrainingOptOut}}checked{{end}}> Training-Opt-out möglich</label>
<label><input type="checkbox" name="training_standard" {{if .TrainingStandard}}checked{{end}}> Training standardmäßig aus</label>
<label for="aufbewahrung_tage">Aufbewahrung (Tage)</label>
<label for="aufbewahrung_tage">Aufbewahrung (Tage, leer lassen wenn vom Anbieter nicht beziffert)</label>
<input type="number" id="aufbewahrung_tage" name="aufbewahrung_tage" min="0" value="{{.AufbewahrungTage}}">
<label for="zertifizierungen">Zertifizierungen (kommagetrennt)</label>
<input type="text" id="zertifizierungen" name="zertifizierungen" value="{{.Zertifizierungen}}">
<label for="geeignete_zwecke">Geeignete Zwecke (kommagetrennt)</label>
<input type="text" id="geeignete_zwecke" name="geeignete_zwecke" value="{{.GeeigneteZwecke}}">
<label for="subprozessoren">Subprozessoren (kommagetrennt, z. B. Anthropic, AWS)</label>
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
<fieldset>
<legend>Geeignete Zwecke</legend>
{{range .ZweckeOptionen}}
<label><input type="checkbox" name="geeignete_zwecke" value="{{.}}" {{if index $.ZweckeAusgewaehlt .}}checked{{end}}> {{.}}</label>
{{end}}
</fieldset>
<label for="einschraenkungen">Einschränkungen (kommagetrennt)</label>
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">

View File

@@ -9,15 +9,32 @@
{{if not .Werkzeuge}}
<p class="hinweis">Noch keine zentralen Katalogeinträge.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Werkzeuge}}
<li>
<a href="/betreiber/werkzeuge/{{.ID}}">
<strong>{{.Name}}</strong> · {{.Anbieter}} · {{.Verarbeitungsort}} · zuletzt geprüft {{.LetztePruefung}}
</a>
</li>
{{end}}
</ul>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Zertifizierungen</th><th>Geeignete Zwecke</th>
<th>Zuletzt geprüft</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td><a href="/betreiber/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.Zertifizierungen}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<p><a href="/betreiber/werkzeuge/neu">Werkzeug anlegen</a></p>
</div>

View File

@@ -15,24 +15,43 @@
{{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>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>AVV</th>
<th>Training aus</th><th>Aufbewahrung</th><th>Geeignete Zwecke</th><th>Zuletzt geprüft</th><th>Status</th>
</tr>
</thead>
<tbody>
{{range .Werkzeuge}}
<tr>
<td>{{.Name}}</td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}</td>
<td>{{if .TrainingStandard}}ja{{else}}nein{{end}}</td>
<td>{{.Aufbewahrung}}</td>
<td>{{.GeeigneteZwecke}}</td>
<td>{{.LetztePruefung}} <span class="status {{.PruefStatusCSS}}">{{.PruefStatusLabel}}</span></td>
<td>
{{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}}
</td>
</tr>
{{end}}
</li>
{{end}}
</ul>
</tbody>
</table>
</div>
{{end}}
<h2>Eigene Werkzeuge</h2>
@@ -43,13 +62,23 @@
{{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>
<div class="table-scroll">
<table>
<thead>
<tr><th>Name</th><th>Anbieter</th><th>Verarbeitungsort</th><th>Zuletzt geprüft</th></tr>
</thead>
<tbody>
{{range .EigeneWerkzeuge}}
<tr>
<td><a href="/verwaltung/werkzeuge/{{.ID}}">{{.Name}}</a></td>
<td>{{.Anbieter}}</td>
<td>{{.Verarbeitungsort}}</td>
<td>{{.LetztePruefung}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<p><a href="/verwaltung/werkzeuge/neu">Eigenes Werkzeug anlegen</a></p>
</div>