Compare commits

...

4 Commits

Author SHA1 Message Date
noroot
cbe68b16c9 fix: fieldset/legend durch echte Überschriften ersetzt, globales CSS vervollständigt
<legend> positioniert sich per Spezifikation immer auf der oberen
Rahmenlinie seines <fieldset> - das lässt sich nicht per CSS aufheben,
nur kaschieren. Die vorherige "Nachbesserung" (mehr Padding/Breite auf
der legend) war ein Pflaster, kein Fix - kam prompt wieder zum
Vorschein, sobald eine neue .form-section-Seite entstand. .form-section
ist jetzt ein <div>, die Überschrift ein normales <h3
class="form-section-title"> mit voller CSS-Kontrolle. Betrifft alle
neun Vorlagen mit diesem Muster.

Zusätzlich alle restlichen unstyled Formulare (Login, Passwort-Reset,
Einladung annehmen, Nutzer anlegen, CSV-Import, Abteilung anlegen,
Firma umbenennen, Fall-Entscheiden) auf dasselbe form-card/form-section/
form-grid-Muster gehoben, für durchgängig konsistentes globales CSS
statt gemischter roher und gestylter Formulare. antrag_neu.html (der
Fragebogen) bleibt bewusst unverändert - dessen fieldset/legend haben
keinen Rahmen-Konflikt und sind an bereits getestete :has()-Adaptivlogik
gekoppelt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 12:18:35 +02:00
noroot
7db4707bc5 feat: vollständige Firmendaten (Adresse, Abrechnung) bei Firmenanlage
Bei Firmenanlage (Registrierung + Betreiber-Firmenanlage) müssen jetzt
Adresse (Straße, PLZ, Ort, Land) und Abrechnungsdaten (Rechnungsemail,
optional USt-IdNr.) erfasst werden, nicht nur der Firmenname (Migration
0023). USt-IdNr. bewusst optional - Kleinunternehmer nach §19 UStG
haben keine. Neue Seite /verwaltung/firma (admin-only) zum Einsehen/
Nachtragen für bestehende Firmen. store.CreateAccount nimmt jetzt ein
AccountInput statt nur einen Namen entgegen (Signaturänderung betrifft
~20 Testaufrufe, mechanisch umgestellt). register.html/
betreiber_account_neu.html auf form-card/form-grid umgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:36:05 +02:00
noroot
fa5e68c892 refactor: E-Mail-Vorlagen als Liste + eigene Bearbeiten-Seite statt Formular-Stapel
Auf Nutzerfeedback: die erste Version zeigte alle bekannten Vorlagen als
gestapelte Formulare auf einer Seite. Jetzt wie Werkzeugkatalog/
Nutzerverwaltung: eine Liste (GET .../email-vorlagen) mit Klick auf eine
eigene Bearbeiten-Seite je Typ (GET/POST .../email-vorlagen/{typ}) -
skaliert sauber, wenn mit der Zeit weitere Benachrichtigungstypen
dazukommen, statt eine immer länger werdende Formular-Stapel-Seite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:10:46 +02:00
noroot
d3b171f721 feat: pro Mandant editierbare E-Mail-Vorlagen
Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten
Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die
eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform-
Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell
existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding.

Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert
bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes
Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu
aktualisieren. Fix: zwei partielle Unique-Indizes statt eines
gemeinsamen Constraints, mit Regressionstest abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 10:06:09 +02:00
48 changed files with 1666 additions and 165 deletions

123
CLAUDE.md
View File

@@ -633,6 +633,91 @@ und live per curl gegen echten Server + Postgres verifiziert.
---
## E-Mail-Vorlagen (2026-09-01, Migration 0022)
Auf Nutzerwunsch: alle vom System versendeten E-Mails sollen editierbar
sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen,
durch den Mandanten selbst; für E-Mails, die die Plattform an
Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter
Standard (`/betreiber/email-vorlagen`, nur Betreiber), gesetzt =
mandantenspezifische Übersteuerung (`/verwaltung/email-vorlagen`, nur
Admin) — jeweils ein eigener Nav-Punkt (nicht als weiterer Button auf
einer bestehenden Seite), mit Liste + eigener Bearbeiten-Seite je Typ
(`GET .../email-vorlagen` Liste, `GET/POST .../email-vorlagen/{typ}`
Bearbeiten) statt eines einzigen, mit allen Formularen gestapelten
Screens — auf ausdrücklichen Nutzerwunsch, da mit der Zeit weitere
Benachrichtigungstypen dazukommen sollen und eine gestapelte Liste dann
unübersichtlich würde. `ResolveEmailVorlage` liefert die eigene
Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
Änderung des Plattform-Standards. `emailVorlageTypen` in
`internal/web/email_vorlage_handlers.go` ist die feste, bekannte Liste
der vom System versendeten E-Mails — aktuell nur
`passwort_zuruecksetzen` (die einzige E-Mail, die es bisher gibt),
bewusst keine generische "beliebige E-Mail anlegen"-UI, da jeder Typ an
eine echte Code-Stelle gebunden ist, die ihn tatsächlich versendet
(Platzhalter wie `{{link}}` sind pro Typ verschieden und müssten sonst
geraten werden).
**Bug beim Live-Verifizieren gefunden und behoben:** `UNIQUE
(account_id, typ)` als einzelner Tabellen-Constraint reicht bei
NULLABLE `account_id` NICHT — SQL behandelt NULL nie als gleich zu
NULL, ein Mandant/Betreiber hätte also bei jedem Speichern eine neue
Plattform-Standard-Zeile statt eines Updates bekommen (genau das ist
beim ersten Testlauf passiert: zwei "Version 1"/"Version 2"-Zeilen
gleichzeitig, `ResolveEmailVorlage` griff die falsche). Fix: zwei
partielle Unique-Indizes (`... WHERE account_id IS NULL` /
`... WHERE account_id IS NOT NULL`) statt eines gemeinsamen Constraints
`UpsertEmailVorlage` braucht dafür zwei unterschiedliche
`ON CONFLICT`-Ziele (SQL erlaubt nur ein Ziel je INSERT-Anweisung), mit
dediziertem Regressionstest (`TestUpsertPlattformStandardAktualisiertStattZuDuplizieren`)
abgesichert. Live per curl mit echtem SMTP-Test-Server erneut
verifiziert: zweimaliges Speichern des Plattform-Standards ergibt eine
Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
Übersteuerung bekommt automatisch den zuletzt gesetzten Plattform-Text.
---
## Firmendaten: Adresse und Abrechnung (2026-09-01, Migration 0023)
Auf Nutzerwunsch: bei jeder Firmenanlage (öffentliche Registrierung
`POST /register` UND Betreiber-Firmenanlage `POST /betreiber/accounts`)
müssen jetzt vollständige Firmendaten erfasst werden, nicht nur der
Name. `account` bekommt sechs neue Spalten: `strasse`, `plz`, `ort`,
`land` (Adresse) sowie `ust_id`, `rechnungsemail` (Abrechnung).
Pflichtfelder bei Neuanlage: Name, Straße, PLZ, Ort, Land,
Rechnungsemail. **Bewusst optional:** USt-IdNr. — Kleinunternehmer nach
§19 UStG haben keine, eine Pflichtangabe wäre hier fachlich falsch.
`store.AccountInput` bündelt diese Felder für `CreateAccount` (Signatur
geändert von `(ctx, name string)` auf `(ctx, AccountInput)` — betrifft
~20 Testaufrufe, mechanisch auf `AccountInput{Name: "..."}` umgestellt)
und die neue Methode `UpdateAccountDetails` (Name + alle Firmendaten in
einem Aufruf, getrennt von der bestehenden `UpdateAccount`, die nur den
Namen ändert — z. B. für die schnelle Tippfehlerkorrektur durch den
Betreiber). Migrationsspalten sind `NOT NULL DEFAULT ''` statt einer
harten Pflicht ohne Default: bestehende, vor dieser Migration angelegte
Accounts haben diese Daten schlicht noch nicht, die Migration darf sie
nicht blockieren — die Pflicht gilt nur auf Anwendungsebene für NEUE
Firmen.
**Neue Seite für bestehende Firmen:** `GET/POST /verwaltung/firma`
(Ebene 4, admin-only, eigener Nav-Punkt "Firmendaten") — zum Einsehen
und Nachtragen/Korrigieren der eigenen Adress-/Abrechnungsdaten, auch
für Accounts, die vor dieser Migration entstanden sind und die Felder
sonst dauerhaft leer hätten. `register.html`/`betreiber_account_neu.html`
wurden dabei auf das `.form-card`/`.form-section`/`.form-grid`-Muster
umgestellt (vorher unstylte Rohformulare) — bei sieben-plus Feldern
sonst unübersichtlich.
Live end-to-end verifiziert: beide Anlage-Wege (Registrierung UND
Betreiber-Firmenanlage) speichern alle Felder korrekt, die neue
Firmendaten-Seite zeigt den aktuellen Stand vorausgefüllt und
Änderungen werden korrekt persistiert.
---
## Row-Level-Security (2026-09-01, Migration 0021)
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
@@ -966,6 +1051,44 @@ wie die Formular-Abschnitte angehoben (600/1rem statt 500/0.9375rem) —
für eine einheitliche Überschriften-Hierarchie statt Zufallsgrößen je
nach UA-Stylesheet.
**Endgültige Korrektur — `fieldset`/`legend` durch echte Überschriften
ersetzt (2026-09-01):** die obige "Nachbesserung" war ein Pflaster auf
dem eigentlichen Problem, nicht die Ursache behoben — sie kam prompt
wieder zum Vorschein, sobald eine neue `.form-section`-Seite entstand
(Betreiber-Firmenanlage), und der Nutzer wies zu Recht darauf hin, dass
das "immer wieder derselbe Fehler" sei. Der eigentliche Grund:
`<legend>` positioniert sich **laut Spezifikation immer** auf der
oberen Rahmenlinie seines `<fieldset>` — das ist seine definierte
Funktion (die Box zu betiteln) und lässt sich durch kein Padding/keine
Breite aufheben, nur kaschieren. `.form-section` ist deshalb jetzt ein
`<div>` (kein `<fieldset>` mehr), die Überschrift ein normales `<h3
class="form-section-title">` — verhält sich wie jede andere Überschrift,
volle CSS-Kontrolle, keine Sonderregeln. Betrifft alle neun Vorlagen,
die das Muster nutzen (`register.html`, `betreiber_account_neu.html`,
`firmendaten.html`, `login.html`, `loeschfristen_liste.html`,
`genehmiger_rollen_liste.html`, `freigabe_regeln_liste.html`,
`email_vorlage_bearbeiten.html`, `betreiber_werkzeug_form.html`).
`antrag_neu.html` (der Fragebogen) bleibt bewusst unverändert — dessen
`fieldset`/`legend` sind einfache, nicht-kartierte Elemente ohne
Rahmen-Konflikt, an eine bereits getestete `:has()`-Adaptivlogik
(Folgefragen ein-/ausblenden) gekoppelt; ein Umbau hätte unnötiges
Risiko für rein kosmetischen Gewinn bedeutet, da dort gar kein
sichtbarer Rahmen existiert, mit dem die Legend kollidieren könnte.
**Restliche unstyled Formulare auf dasselbe Muster gehoben
(2026-09-01):** `passwort_vergessen.html`, `passwort_zuruecksetzen.html`,
`einladung.html`, `nutzer_neu.html` (Rollenauswahl jetzt `.chip-group`
statt nacktem `fieldset`), `nutzer_import.html`, `abteilung_liste.html`
(Anlegen-Formular), `betreiber_account_detail.html` (Umbenennen-
Formular), sowie der komplette "Entscheiden"-Bereich in
`fall_detail.html` (Entscheidung jetzt `.chip-group` statt nacktem
`fieldset`) — alle jetzt `.form-card`/`.form-section`/`.form-grid`
statt roher `<label>`+`<input>`-Ketten. Explizit **nicht** angefasst:
`antrag_neu.html` (siehe oben), sowie reine Listen-Aktionsformulare
(Filter, Löschen-/Aktivieren-Buttons in `.beitraege-liste`-Zeilen) —
die haben ihr eigenes, bereits passendes CSS-Muster und keinen
Formular-Charakter im hier gemeinten Sinn.
**enconf-Patterns übertragen (2026-08-31):** enconf (Schwesterprojekt)
ist React + Ant Design (`ProTable`/`StatCard`/`PageHeader`-Komponenten)
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die

View File

@@ -13,30 +13,56 @@ import (
// 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.
// Strasse/PLZ/Ort/Land/UStID/Rechnungsemail sind die Firmen- und
// Abrechnungsdaten (Migration 0023) — bei bestehenden, vor dieser
// Migration angelegten Accounts können sie leer sein, siehe dort.
type Account struct {
ID string
Name string
EinladungToken string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
CreatedAt time.Time
}
const accountColumns = `id, name, einladung_token, created_at`
// AccountInput bündelt die Firmendaten für CreateAccount/
// UpdateAccountDetails. Name ist die einzige Pflichtangabe auf
// Store-Ebene — welche der übrigen Felder ein Formular tatsächlich
// verlangt (z. B. Adresse bei Neuanlage), entscheidet die Web-Schicht,
// nicht der Store (Tests legen Accounts oft ohne vollständige
// Firmendaten an, das ist auf Store-Ebene kein Fehler).
type AccountInput struct {
Name string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
}
const accountColumns = `id, name, einladung_token, strasse, plz, ort, land, ust_id, rechnungsemail, created_at`
func scanAccount(row interface {
Scan(dest ...any) error
}) (Account, error) {
var a Account
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.Strasse, &a.PLZ, &a.Ort, &a.Land, &a.UStID, &a.Rechnungsemail, &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) {
func (s *Store) CreateAccount(ctx context.Context, in AccountInput) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
INSERT INTO account (name, strasse, plz, ort, land, ust_id, rechnungsemail)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+accountColumns,
name,
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
)
a, err := scanAccount(row)
if err != nil {
@@ -46,7 +72,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
}
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
// Betreiber-gestützten Anlage korrigieren).
// Betreiber-gestützten Anlage korrigieren) — ändert bewusst nur den
// Namen, nicht die übrigen Firmendaten, siehe UpdateAccountDetails.
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
a, err := scanAccount(row)
@@ -59,6 +86,26 @@ func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, er
return a, nil
}
// UpdateAccountDetails aktualisiert Name und Firmen-/Abrechnungsdaten
// gemeinsam — genutzt von der Firmendaten-Seite (Ebene 4, admin), auf
// der ein Mandant seine eigenen Angaben pflegt/nachträgt.
func (s *Store) UpdateAccountDetails(ctx context.Context, id string, in AccountInput) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `
UPDATE account SET name = $2, strasse = $3, plz = $4, ort = $5, land = $6, ust_id = $7, rechnungsemail = $8
WHERE id = $1
RETURNING `+accountColumns,
id, in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: update account details: %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.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)

View File

@@ -3,6 +3,8 @@ package store_test
import (
"context"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
@@ -37,7 +39,7 @@ func TestListAccounts(t *testing.T) {
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Neuer Mandant fuer ListAccounts"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -30,10 +30,63 @@ func TestAccountCRUD(t *testing.T) {
}
}
func TestCreateAccountMitFirmendaten(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{
Name: "Vollstaendig GmbH", Strasse: "Musterstraße 1", PLZ: "12345", Ort: "Musterstadt",
Land: "Deutschland", UStID: "DE123456789", Rechnungsemail: "rechnung@vollstaendig.example.com",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Strasse != "Musterstraße 1" || acc.PLZ != "12345" || acc.Ort != "Musterstadt" ||
acc.Land != "Deutschland" || acc.UStID != "DE123456789" || acc.Rechnungsemail != "rechnung@vollstaendig.example.com" {
t.Fatalf("Account = %+v, Firmendaten unvollständig gespeichert", acc)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Rechnungsemail != acc.Rechnungsemail {
t.Fatalf("Rechnungsemail nach GetAccount = %q, want %q", got.Rechnungsemail, acc.Rechnungsemail)
}
}
func TestUpdateAccountDetails(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alte Firma", Ort: "Altstadt"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
updated, err := s.UpdateAccountDetails(ctx, acc.ID, store.AccountInput{
Name: "Neue Firma", Strasse: "Neue Straße 2", PLZ: "54321", Ort: "Neustadt",
Land: "Österreich", UStID: "", Rechnungsemail: "buchhaltung@neue-firma.example.com",
})
if err != nil {
t.Fatalf("UpdateAccountDetails: %v", err)
}
if updated.Name != "Neue Firma" || updated.Ort != "Neustadt" || updated.Land != "Österreich" {
t.Fatalf("Account nach Update = %+v, nicht wie erwartet aktualisiert", updated)
}
}
func TestUpdateAccountDetailsNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, err := s.UpdateAccountDetails(ctx, "00000000-0000-0000-0000-000000000000", store.AccountInput{Name: "X"})
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestUpdateAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Alter Name GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alter Name GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -66,7 +119,7 @@ func TestUpdateAccountNotFound(t *testing.T) {
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -88,7 +141,7 @@ func TestGetAccountByEinladungToken(t *testing.T) {
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -0,0 +1,116 @@
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type EmailVorlage struct {
ID string
AccountID *string
Typ string
Betreff string
Text string
UpdatedAt time.Time
}
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
// accountID nil schreibt den plattformweiten Standard (nur für den
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
//
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
var v EmailVorlage
var err error
if accountID == nil {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES (NULL, $1, $2, $3)
ON CONFLICT (typ) WHERE account_id IS NULL
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
} else {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
}
return v, nil
}
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
// accountID nil sucht den plattformweiten Standard.
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
}
return v, nil
}
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
// Plattform-Standard wird per Migration angelegt).
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
ORDER BY account_id NULLS LAST
LIMIT 1
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
}
return v, nil
}
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
// wieder auf den plattformweiten Standard zurück), beim Betreiber
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
tag, err := s.db(ctx).Exec(ctx, `
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ)
if err != nil {
return fmt.Errorf("store: delete email vorlage: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,117 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
}
if v.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
}
if v.Betreff == "" || v.Text == "" {
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
}
}
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
}
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
}
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
}
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
t.Fatalf("DeleteEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
}
}
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
s := openTestStore(t)
ctx := context.Background()
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (1): %v", err)
}
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (2): %v", err)
}
if first.ID != second.ID {
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
}
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage: %v", err)
}
if got.Betreff != "Zweiter Betreff" {
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
}
}
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
t.Fatalf("UpsertEmailVorlage A: %v", err)
}
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage B: %v", err)
}
if resolvedB.Betreff == "Vorlage A" {
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
}
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
}
}

View File

@@ -0,0 +1,4 @@
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
DROP TABLE email_vorlage;

View File

@@ -0,0 +1,55 @@
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
-- internal/web/email_vorlage_handlers.go).
CREATE TABLE email_vorlage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES account(id),
typ TEXT NOT NULL,
betreff TEXT NOT NULL,
text TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
-- Plattformweiter Standard für die einzige aktuell existierende
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
NULL,
'passwort_zuruecksetzen',
'Deklarix — Passwort zurücksetzen',
'Hallo,' || E'\n\n' ||
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
'{{link}}' || E'\n\n' ||
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
);
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
-- account_id-Zeile anlegen/ändern.
CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
account_id IS NULL
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
);
ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;

View File

@@ -0,0 +1,6 @@
ALTER TABLE account DROP COLUMN strasse;
ALTER TABLE account DROP COLUMN plz;
ALTER TABLE account DROP COLUMN ort;
ALTER TABLE account DROP COLUMN land;
ALTER TABLE account DROP COLUMN ust_id;
ALTER TABLE account DROP COLUMN rechnungsemail;

View File

@@ -0,0 +1,20 @@
-- Adress- und Abrechnungsdaten der Firma — bisher trug account nur den
-- Namen. NOT NULL DEFAULT '' statt einer harten NOT-NULL-Pflicht ohne
-- Default: bestehende Accounts (vor dieser Migration angelegt) haben
-- diese Daten schlicht noch nicht, das darf die Migration nicht
-- blockieren. Die Anwendungsschicht erzwingt Pflichtfelder nur für NEU
-- angelegte Firmen (Registrierung, Betreiber-Firmenanlage); bestehende
-- Firmen werden nicht rückwirkend gezwungen, sie können es über die
-- neue Firmendaten-Seite (Ebene 4) nachtragen.
ALTER TABLE account ADD COLUMN strasse TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN plz TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN ort TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN land TEXT NOT NULL DEFAULT '';
-- Umsatzsteuer-ID ist bewusst optional (NOT NULL DEFAULT '', keine
-- Pflicht auch bei Neuanlage) — Kleinunternehmer nach §19 UStG haben
-- keine.
ALTER TABLE account ADD COLUMN ust_id TEXT NOT NULL DEFAULT '';
-- Rechnungsemail kann von der E-Mail des ersten (admin-)Logins
-- abweichen (z. B. eine buchhaltung@-Adresse) — eigenes Feld statt
-- Wiederverwendung der Login-E-Mail.
ALTER TABLE account ADD COLUMN rechnungsemail TEXT NOT NULL DEFAULT '';

View File

@@ -36,7 +36,7 @@ func openTestStore(t *testing.T) *store.Store {
// testAccountID legt einen Mandanten an und liefert dessen ID.
func testAccountID(t *testing.T, s *store.Store) string {
t.Helper()
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
acc, err := s.CreateAccount(context.Background(), store.AccountInput{Name: "Test-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
)
type authPageData struct {
@@ -46,11 +47,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
accountName := r.FormValue("account_name")
accountInput := store.AccountInput{
Name: r.FormValue("account_name"),
Strasse: r.FormValue("strasse"),
PLZ: r.FormValue("plz"),
Ort: r.FormValue("ort"),
Land: r.FormValue("land"),
UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer)
Rechnungsemail: r.FormValue("rechnungsemail"),
}
email := r.FormValue("email")
password := r.FormValue("password")
if accountName == "" || email == "" || password == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
if accountInput.Name == "" || email == "" || password == "" ||
accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" ||
accountInput.Land == "" || accountInput.Rechnungsemail == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder außer USt-IdNr. sind Pflicht"})
return
}
@@ -68,7 +79,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
// RLS-geschützt) von der Policy abgelehnt.
var errMsg string
err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error {
acc, err := s.store.CreateAccount(ctx, accountName)
acc, err := s.store.CreateAccount(ctx, accountInput)
if err != nil {
errMsg = "Konto konnte nicht angelegt werden"
return err

View File

@@ -6,6 +6,8 @@ import (
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
@@ -13,9 +15,9 @@ func TestBetreiberKannFirmaAnlegen(t *testing.T) {
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", mergeValues(url.Values{
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
})
}, firmenPflichtfelder()))
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
@@ -62,7 +64,7 @@ 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")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Alter Name"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -111,11 +111,21 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountName := r.FormValue("account_name")
accountInput := store.AccountInput{
Name: r.FormValue("account_name"),
Strasse: r.FormValue("strasse"),
PLZ: r.FormValue("plz"),
Ort: r.FormValue("ort"),
Land: r.FormValue("land"),
UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer)
Rechnungsemail: r.FormValue("rechnungsemail"),
}
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 accountInput.Name == "" || email == "" || password == "" ||
accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" ||
accountInput.Land == "" || accountInput.Rechnungsemail == "" {
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht"}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
@@ -132,7 +142,7 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
}
ctx := r.Context()
acc, err := s.store.CreateAccount(ctx, accountName)
acc, err := s.store.CreateAccount(ctx, accountInput)
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 {
@@ -161,7 +171,7 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
}
return
}
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountInput.Name+" / "+email); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -106,7 +106,7 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(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(), "Mandant mit eigenem Werkzeug")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mandant mit eigenem Werkzeug"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -6,12 +6,14 @@ import (
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestEinladungFormZeigtFirmenname(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -41,7 +43,7 @@ func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -0,0 +1,207 @@
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
// der Betreiber pflegt den plattformweiten Standard
// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
// feste, bekannte Liste der vom System versendeten E-Mails — aktuell
// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt,
// aber bewusst als Liste-mit-Bearbeiten-Seite gebaut (wie Werkzeugkatalog/
// Nutzerverwaltung), nicht als eine Seite mit allen Formularen gestapelt
// — mit der Zeit kommen weitere Benachrichtigungstypen dazu.
package web
import (
"errors"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/store"
)
type emailVorlageTypInfo struct {
Typ string
Label string
Platzhalter string
}
var emailVorlageTypen = []emailVorlageTypInfo{
{
Typ: "passwort_zuruecksetzen",
Label: "Passwort zurücksetzen",
Platzhalter: "{{link}} — der Zurücksetzen-Link, wird beim Versand eingesetzt.",
},
}
func findEmailVorlageTyp(typ string) (emailVorlageTypInfo, bool) {
for _, t := range emailVorlageTypen {
if t.Typ == typ {
return t, true
}
}
return emailVorlageTypInfo{}, false
}
// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
// Vorlage — aktuell nur {{link}} (Passwort-Zurücksetzen-Link).
func renderEmailVorlage(text, link string) string {
return strings.ReplaceAll(text, "{{link}}", link)
}
// ─── Mandant (Ebene 4, admin-only): eigene Übersteuerung ───────────────
type emailVorlageZeile struct {
Typ string
Label string
IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
}
type emailVorlagenListeData struct {
Title string
Nav navData
Zeilen []emailVorlageZeile
ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
}
func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen", Nav: navFor(r), ActionBase: "/verwaltung/email-vorlagen"}
for _, t := range emailVorlageTypen {
_, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label, IstUebersteuert: err == nil})
}
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type emailVorlageBearbeitenData struct {
Title string
Nav navData
Typ string
Label string
Platzhalter string
Betreff string
Text string
IstUebersteuert bool
ActionBase string
Error string
Gespeichert bool
}
func (s *Server) handleEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
info, ok := findEmailVorlageTyp(typ)
if !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
accountID := currentUser(r).AccountID
resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, typ)
if err != nil {
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
_, uebersteuert := s.store.GetEmailVorlage(r.Context(), &accountID, typ)
data := emailVorlageBearbeitenData{
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: uebersteuert == nil,
ActionBase: "/verwaltung/email-vorlagen", Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := currentUser(r).AccountID
betreff := strings.TrimSpace(r.FormValue("betreff"))
text := strings.TrimSpace(r.FormValue("text"))
if betreff == "" || text == "" {
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
return
}
if _, err := s.store.UpsertEmailVorlage(r.Context(), &accountID, typ, betreff, text); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}
func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
accountID := currentUser(r).AccountID
if err := s.store.DeleteEmailVorlage(r.Context(), &accountID, typ); err != nil && !errors.Is(err, store.ErrNotFound) {
http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}
// ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r), ActionBase: "/betreiber/email-vorlagen"}
for _, t := range emailVorlageTypen {
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label})
}
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleBetreiberEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
info, ok := findEmailVorlageTyp(typ)
if !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
v, err := s.store.GetEmailVorlage(r.Context(), nil, typ)
if err != nil {
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := emailVorlageBearbeitenData{
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
Betreff: v.Betreff, Text: v.Text, ActionBase: "/betreiber/email-vorlagen",
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
typ := r.PathValue("typ")
if _, ok := findEmailVorlageTyp(typ); !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
betreff := strings.TrimSpace(r.FormValue("betreff"))
text := strings.TrimSpace(r.FormValue("text"))
if betreff == "" || text == "" {
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
return
}
if _, err := s.store.UpsertEmailVorlage(r.Context(), nil, typ, betreff, text); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/betreiber/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
}

View File

@@ -0,0 +1,113 @@
package web_test
import (
"net/http"
"net/url"
"regexp"
"strings"
"testing"
)
func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
listResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
if listResp.Code != http.StatusOK {
t.Fatalf("Liste-Status = %d, want 200", listResp.Code)
}
if !strings.Contains(listResp.Body.String(), "/verwaltung/email-vorlagen/passwort_zuruecksetzen") {
t.Fatalf("erwartet Link zur Bearbeiten-Seite, body: %s", listResp.Body.String())
}
getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
if getResp.Code != http.StatusOK {
t.Fatalf("GET Bearbeiten-Seite status = %d, want 200", getResp.Code)
}
if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") {
t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String())
}
saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"},
})
if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
}
afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
body := afterResp.Body.String()
if !strings.Contains(body, "Mein eigener Betreff") {
t.Fatalf("gespeicherter Betreff fehlt, body: %s", body)
}
if !strings.Contains(body, "(eigene Vorlage)") {
t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body)
}
resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen/zuruecksetzen", url.Values{})
if resetResp.Code != http.StatusSeeOther {
t.Fatalf("reset status = %d, want 303", resetResp.Code)
}
finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") {
t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv")
}
}
func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber")
saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"},
})
if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
}
getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen")
if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") {
t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String())
}
}
func TestNichtAdminKannEmailVorlagenNichtBearbeiten(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/email-vorlagen")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.Code)
}
}
func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) {
fs := newFakeStore()
s, fm := newServerWithMailer(t, fs)
cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin")
if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."},
}); resp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", resp.Code)
}
if resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"vorlage-admin@example.com"}}); resp.Code != http.StatusOK {
t.Fatalf("passwort-vergessen status = %d, want 200", resp.Code)
}
if len(fm.Sent) != 1 {
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
}
if fm.Sent[0].Subject != "Firmenspezifischer Betreff" {
t.Fatalf("Subject = %q, want die firmenspezifische Vorlage", fm.Sent[0].Subject)
}
if !strings.Contains(fm.Sent[0].Body, "Hallo! Link: ") || strings.Contains(fm.Sent[0].Body, "{{link}}") {
t.Fatalf("Body = %q, want Platzhalter ersetzt und Rest der eigenen Vorlage erhalten", fm.Sent[0].Body)
}
if !regexp.MustCompile(`/passwort-zuruecksetzen/[a-f0-9]+`).MatchString(fm.Sent[0].Body) {
t.Fatalf("kein Reset-Link im Body gefunden: %q", fm.Sent[0].Body)
}
}

View File

@@ -19,7 +19,7 @@ import (
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Fachebene-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -62,7 +62,7 @@ func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
}
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Anderer Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -174,7 +174,7 @@ func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testi
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
fs := newFakeStore()
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mit Katalog"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -225,7 +225,7 @@ func TestFallDetailRejectsForeignAccount(t *testing.T) {
s := newServer(t, fs)
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Fremder Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -0,0 +1,74 @@
// Firmendaten (Migration 0023) — Adress- und Abrechnungsdaten des
// eigenen Mandanten einsehen/pflegen. Bei der Neuanlage (Registrierung,
// Betreiber-Firmenanlage) sind diese Felder Pflicht; bestehende, davor
// angelegte Accounts können sie hier nachtragen.
package web
import (
"net/http"
"github.com/netcell-it/deklarix/internal/store"
)
type firmendatenData struct {
Title string
Nav navData
Name string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
Error string
Gespeichert bool
}
func (s *Server) handleFirmendaten(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Firmendaten konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := firmendatenData{
Title: "Firmendaten", Nav: navFor(r),
Name: acc.Name, Strasse: acc.Strasse, PLZ: acc.PLZ, Ort: acc.Ort,
Land: acc.Land, UStID: acc.UStID, Rechnungsemail: acc.Rechnungsemail,
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
}
if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleFirmendatenSpeichern(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
in := store.AccountInput{
Name: r.FormValue("account_name"),
Strasse: r.FormValue("strasse"),
PLZ: r.FormValue("plz"),
Ort: r.FormValue("ort"),
Land: r.FormValue("land"),
UStID: r.FormValue("ust_id"),
Rechnungsemail: r.FormValue("rechnungsemail"),
}
if in.Name == "" || in.Strasse == "" || in.PLZ == "" || in.Ort == "" || in.Land == "" || in.Rechnungsemail == "" {
data := firmendatenData{
Title: "Firmendaten", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht",
Name: in.Name, Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land,
UStID: in.UStID, Rechnungsemail: in.Rechnungsemail,
}
if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
return
}
if _, err := s.store.UpdateAccountDetails(r.Context(), currentUser(r).AccountID, in); err != nil {
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/firma?gespeichert=1", http.StatusSeeOther)
}

View File

@@ -0,0 +1,87 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestRegisterRequiresFirmenadresse(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Unvollständig GmbH"}, "email": {"unvollstaendig@example.com"}, "password": {"ein-sicheres-passwort"},
// Adresse/Rechnungsemail fehlen bewusst.
})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Pflicht") {
t.Fatalf("erwartet Validierungsfehler, body: %s", resp.Body.String())
}
}
func TestRegisterSpeichertFirmendaten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/register", mergeValues(url.Values{
"account_name": {"Vollständig GmbH"}, "email": {"admin@vollstaendig.example.com"}, "password": {"ein-sicheres-passwort"},
}, firmenPflichtfelder()))
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
user, err := fs.GetUserByEmail(context.Background(), "admin@vollstaendig.example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
acc, err := fs.GetAccount(context.Background(), user.AccountID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if acc.Strasse != "Teststraße 1" || acc.PLZ != "12345" || acc.Rechnungsemail != "rechnung@example.com" {
t.Fatalf("Account = %+v, Firmendaten nicht wie erwartet gespeichert", acc)
}
}
func TestAdminKannFirmendatenBearbeiten(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Alte Firma", "admin@example.com", "admin")
getResp := getWithCookie(t, s, cookie, "/verwaltung/firma")
if getResp.Code != http.StatusOK {
t.Fatalf("GET status = %d, want 200", getResp.Code)
}
if !strings.Contains(getResp.Body.String(), "Alte Firma") {
t.Fatalf("erwartet aktuellen Firmennamen im Formular, body: %s", getResp.Body.String())
}
saveResp := postForm(t, s, cookie, "/verwaltung/firma", url.Values{
"account_name": {"Neue Firma"}, "strasse": {"Neue Straße 5"}, "plz": {"99999"}, "ort": {"Neustadt"},
"land": {"Deutschland"}, "rechnungsemail": {"buchhaltung@neue-firma.example.com"},
})
if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
}
afterResp := getWithCookie(t, s, cookie, "/verwaltung/firma")
if !strings.Contains(afterResp.Body.String(), "Neue Firma") || !strings.Contains(afterResp.Body.String(), "Neustadt") {
t.Fatalf("gespeicherte Firmendaten fehlen, body: %s", afterResp.Body.String())
}
}
func TestNichtAdminKannFirmendatenNichtBearbeiten(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/firma")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.Code)
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
@@ -32,7 +33,7 @@ func besondereKategorieAntragForm() url.Values {
func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, "Freigabe-Mandant")
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Freigabe-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -36,13 +36,16 @@ func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request)
scheme = "https"
}
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
body := "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n" + link + "\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
betreff, text := "Deklarix — Passwort zurücksetzen", "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
if vorlage, err := s.store.ResolveEmailVorlage(r.Context(), user.AccountID, "passwort_zuruecksetzen"); err == nil {
betreff, text = vorlage.Betreff, vorlage.Text
}
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
// (keine Enumeration über unterschiedliches Verhalten bei
// Versandfehlern), aber serverseitig geloggt statt
// verschluckt — sonst bleibt ein dauerhaft falsch
// konfigurierter SMTP-Server unbemerkt.
if err := s.mailer.Send(user.Email, "Deklarix — Passwort zurücksetzen", body); err != nil {
if err := s.mailer.Send(user.Email, betreff, renderEmailVorlage(text, link)); err != nil {
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
}
}

View File

@@ -13,10 +13,10 @@ func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
fs := newFakeStore()
s, fm := newServerWithMailer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
"password": {"altes-passwort"},
})
}, firmenPflichtfelder()))
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303", regResp.Code)
}

View File

@@ -16,7 +16,7 @@ import (
// einen tatsächlichen Registereintrag brauchen.
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
t.Helper()
acc, err := fs.CreateAccount(context.Background(), "Register-Mandant")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Register-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}

View File

@@ -38,8 +38,9 @@ type Store interface {
WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error
SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error
CreateAccount(ctx context.Context, name string) (store.Account, error)
CreateAccount(ctx context.Context, in store.AccountInput) (store.Account, error)
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (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
@@ -52,6 +53,10 @@ type Store interface {
SetUserPassword(ctx context.Context, id, passwordHash string) error
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error)
GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error)
ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error)
DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
@@ -162,6 +167,15 @@ func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, erro
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
mux.HandleFunc("GET /verwaltung/firma", s.requireAdmin(s.handleFirmendaten))
mux.HandleFunc("POST /verwaltung/firma", s.requireAdmin(s.handleFirmendatenSpeichern))
mux.HandleFunc("GET /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe))
mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm))
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageSpeichern))
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen))
mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
mux.HandleFunc("GET /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageBearbeitenForm))
mux.HandleFunc("POST /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern))
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)

View File

@@ -45,7 +45,18 @@ type fakeStore struct {
freigabeRegeln map[string]store.FreigabeRegel
freigabeschritte map[string]store.Freigabeschritt
passwordResetTokens map[string]store.PasswordResetToken
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
emailVorlagen map[string]store.EmailVorlage // key: accountKeyFor(accountID)+"|"+typ
}
// accountKeyFor macht nil und "" für die Plattform-Vorlage im
// fakeStore-Key ununterscheidbar von einer echten, aber leeren
// account_id — genügt für Tests, da echte account_ids nie leer sind.
func accountKeyFor(accountID *string) string {
if accountID == nil {
return ""
}
return *accountID
}
func newFakeStore() *fakeStore {
@@ -67,6 +78,16 @@ func newFakeStore() *fakeStore {
freigabeschritte: map[string]store.Freigabeschritt{},
passwordResetTokens: map[string]store.PasswordResetToken{},
loeschfristen: map[string]map[string]int{},
emailVorlagen: map[string]store.EmailVorlage{
// Platzhalter für den plattformweiten Standard, wie ihn
// Migration 0022 für echtes Postgres seedet — sonst würde
// ResolveEmailVorlage im fakeStore ErrNotFound liefern,
// wo die echte DB immer einen Treffer hat.
"|passwort_zuruecksetzen": {
Typ: "passwort_zuruecksetzen", Betreff: "Deklarix — Passwort zurücksetzen",
Text: "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail.",
},
},
}
}
@@ -81,16 +102,62 @@ func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetr
return nil
}
func (f *fakeStore) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
key := accountKeyFor(accountID) + "|" + typ
v := store.EmailVorlage{ID: f.newID(), AccountID: accountID, Typ: typ, Betreff: betreff, Text: text, UpdatedAt: time.Now()}
f.emailVorlagen[key] = v
return v, nil
}
func (f *fakeStore) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.emailVorlagen[accountKeyFor(accountID)+"|"+typ]
if !ok {
return store.EmailVorlage{}, store.ErrNotFound
}
return v, nil
}
func (f *fakeStore) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error) {
f.mu.Lock()
defer f.mu.Unlock()
if v, ok := f.emailVorlagen[accountID+"|"+typ]; ok {
return v, nil
}
if v, ok := f.emailVorlagen["|"+typ]; ok {
return v, nil
}
return store.EmailVorlage{}, store.ErrNotFound
}
func (f *fakeStore) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
f.mu.Lock()
defer f.mu.Unlock()
key := accountKeyFor(accountID) + "|" + typ
if _, ok := f.emailVorlagen[key]; !ok {
return store.ErrNotFound
}
delete(f.emailVorlagen, key)
return nil
}
func (f *fakeStore) newID() string {
f.nextID++
return fmt.Sprintf("id-%d", f.nextID)
}
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
func (f *fakeStore) CreateAccount(ctx context.Context, in store.AccountInput) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
id := f.newID()
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
acc := store.Account{
ID: id, Name: in.Name, EinladungToken: "einladung-token-" + id,
Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land,
UStID: in.UStID, Rechnungsemail: in.Rechnungsemail, CreatedAt: time.Now(),
}
f.accounts[acc.ID] = acc
return acc, nil
}
@@ -107,6 +174,19 @@ func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.A
return acc, nil
}
func (f *fakeStore) UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
acc.Name, acc.Strasse, acc.PLZ, acc.Ort, acc.Land, acc.UStID, acc.Rechnungsemail =
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail
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()
@@ -887,7 +967,7 @@ func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMa
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, accountName)
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: accountName})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -947,6 +1027,28 @@ func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
return s, fs, cookie
}
// firmenPflichtfelder liefert die seit Migration 0023 bei Firmenanlage
// (Registrierung und Betreiber-Firmenanlage) verlangten Adress-/
// Abrechnungsfelder — gemeinsame Basis für Tests, die nicht speziell
// deren Validierung prüfen, sonst müsste jeder Testfall sie einzeln
// nachpflegen.
func firmenPflichtfelder() url.Values {
return url.Values{
"strasse": {"Teststraße 1"}, "plz": {"12345"}, "ort": {"Teststadt"},
"land": {"Deutschland"}, "rechnungsemail": {"rechnung@example.com"},
}
}
// mergeValues kopiert alle Werte aus extra in base (base wird mutiert
// und zurückgegeben) — für Tests, die ein Basisformular um wenige
// eigene Felder ergänzen wollen.
func mergeValues(base url.Values, extra url.Values) url.Values {
for k, v := range extra {
base[k] = v
}
return base
}
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
@@ -1008,10 +1110,10 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
"password": {"ein-sicheres-passwort"},
})
}, firmenPflichtfelder()))
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
@@ -1038,10 +1140,10 @@ func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
"password": {"ein-sicheres-passwort"},
})
}, firmenPflichtfelder()))
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
@@ -1084,10 +1186,10 @@ func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) {
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
form := url.Values{
form := mergeValues(url.Values{
"account_name": {"A"}, "email": {"doppelt@example.com"},
"password": {"ein-sicheres-passwort"},
}
}, firmenPflichtfelder())
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
t.Fatalf("first register status = %d, want 303", resp.Code)
}
@@ -1107,7 +1209,7 @@ func TestLoginWithCorrectPassword(t *testing.T) {
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Bestehender Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -1131,7 +1233,7 @@ func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Deklarix Betreiber"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -1155,7 +1257,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, _ := fs.CreateAccount(context.Background(), "X")
acc, _ := fs.CreateAccount(context.Background(), store.AccountInput{Name: "X"})
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}

View File

@@ -582,6 +582,16 @@ button.entfernen {
margin-bottom: 0;
}
/* form-section ist bewusst ein <div>, kein <fieldset> — <legend>
positioniert sich per Spezifikation IMMER auf der oberen Rahmenlinie
des <fieldset> (das ist seine ganze Funktion: die Box zu betiteln),
das lässt sich nicht per CSS aufheben, nur mit fragilen Hacks
kaschieren (padding/Breite anpassen ändert nichts an der
Border-Überlappung selbst — genau das führte zu einer bereits einmal
"reparierten" Überschrift, die nach der nächsten neuen Seite wieder
zu klein/zu randnah aussah). Eine echte Übersicht (.form-section-title)
verhält sich wie jede normale Überschrift: volle CSS-Kontrolle, keine
browserseitigen Sonderregeln. */
.form-section {
border: none;
margin: 0;
@@ -593,16 +603,11 @@ button.entfernen {
border-top: 1px solid var(--color-border);
}
/* <legend> ignoriert das padding des umschließenden <fieldset> (es
positioniert sich an dessen Border-Box, nicht an der Padding-Box) —
ohne eigenes padding-left säße die Abschnittsüberschrift bündig am
Kartenrand, während der restliche Inhalt eingerückt ist. */
.form-section > legend {
padding: 0 0 16px 0;
.form-section-title {
margin: 0 0 16px 0;
font-size: 1.0625rem;
font-weight: 700;
color: var(--color-heading);
width: 100%;
}
.form-grid {

View File

@@ -25,12 +25,22 @@
{{end}}
</ul>
{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/abteilungen">
<label for="name">Neue Abteilung</label>
<input type="text" id="name" name="name" required>
<button type="submit">Anlegen</button>
<div class="form-section">
<div class="form-grid">
<div>
<label for="name">Neue Abteilung</label>
<input type="text" id="name" name="name" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -8,11 +8,21 @@
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<div class="form-card">
<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>
<div class="form-section">
<div class="form-grid">
<div>
<label for="name">Firmenname</label>
<input type="text" id="name" name="name" value="{{.Name}}" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Umbenennen</button>
</div>
</form>
</div>
<h2>Nutzer</h2>
<ul class="beteiligte">

View File

@@ -11,19 +11,65 @@
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<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>
<div class="form-section">
<h3 class="form-section-title">Firma</h3>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="Deutschland" required>
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Abrechnung</h3>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" placeholder="z. B. DE123456789">
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Erster Nutzer (wird Admin)</h3>
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Firma anlegen</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -24,6 +24,7 @@
<span class="stat-card-label">Audit-Log (neueste)</span>
</a>
</li>
<li><a class="stat-card stat-card-action" href="/betreiber/email-vorlagen"><span class="stat-card-label">E-Mail-Vorlagen &rarr;</span></a></li>
</ul>
</div>
</body>

View File

@@ -14,8 +14,8 @@
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
<fieldset class="form-section">
<legend>Grunddaten</legend>
<div class="form-section">
<h3 class="form-section-title">Grunddaten</h3>
<div class="form-grid">
<div>
<label for="name">Name</label>
@@ -35,10 +35,10 @@
<p class="hinweis">Tatsächliche Länder benennen, nicht "EU"/"Drittland" — die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere.</p>
</div>
</div>
</fieldset>
</div>
<fieldset class="form-section">
<legend>Rechtliches &amp; Datenschutz</legend>
<div class="form-section">
<h3 class="form-section-title">Rechtliches &amp; Datenschutz</h3>
<div class="form-grid">
<div class="form-full">
<label>Zusicherungen</label>
@@ -66,10 +66,10 @@
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
</div>
</div>
</fieldset>
</div>
<fieldset class="form-section">
<legend>Einsatzbereich</legend>
<div class="form-section">
<h3 class="form-section-title">Einsatzbereich</h3>
<div class="form-grid">
<div class="form-full">
<label>Geeignete Zwecke</label>
@@ -84,10 +84,10 @@
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
</div>
</div>
</fieldset>
</div>
<fieldset class="form-section">
<legend>Beleg</legend>
<div class="form-section">
<h3 class="form-section-title">Beleg</h3>
<div class="form-grid">
<div>
<label for="letzte_pruefung">Letzte Prüfung</label>
@@ -98,7 +98,7 @@
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
</div>
</div>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>

View File

@@ -6,15 +6,25 @@
<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}}
<div class="form-card">
<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>
<div class="form-section">
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Konto anlegen</button>
</div>
</form>
</div>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>

View File

@@ -0,0 +1,49 @@
{{define "email-vorlage-bearbeiten"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<p><a href="{{.ActionBase}}">&larr; E-Mail-Vorlagen</a></p>
<div class="page-header">
<div>
<h1>{{.Label}}</h1>
<p class="hinweis">
Platzhalter in geschweiften Klammern werden beim Versand ersetzt.
Verfügbar: {{.Platzhalter}}
</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
<div class="form-card">
<form method="post" action="{{.ActionBase}}/{{.Typ}}">
<div class="form-section">
<h3 class="form-section-title">{{.Label}}{{if .IstUebersteuert}} <span class="hinweis">(eigene Vorlage)</span>{{else if eq .ActionBase "/verwaltung/email-vorlagen"}} <span class="hinweis">(Plattform-Standard, nicht angepasst)</span>{{end}}</h3>
<div class="form-grid">
<div class="form-full">
<label for="betreff">Betreff</label>
<input type="text" id="betreff" name="betreff" value="{{.Betreff}}" required>
</div>
<div class="form-full">
<label for="text">Text</label>
<textarea id="text" name="text" rows="10" required>{{.Text}}</textarea>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
{{if and .IstUebersteuert (eq .ActionBase "/verwaltung/email-vorlagen")}}
<div class="form-gefahrenzone">
<form method="post" action="/verwaltung/email-vorlagen/{{.Typ}}/zuruecksetzen" onsubmit="return confirm('Eigene Vorlage löschen und zum Plattform-Standard zurückkehren?')">
<button type="submit" class="entfernen">Auf Plattform-Standard zurücksetzen</button>
</form>
</div>
{{end}}
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,30 @@
{{define "email-vorlagen-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page page-wide">
<div class="page-header">
<div>
<h1>{{.Title}}</h1>
<p class="hinweis">Betreff und Text der vom System versendeten E-Mails.</p>
</div>
</div>
{{if not .Zeilen}}
<p class="hinweis">Es gibt noch keine vom System versendeten E-Mails.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Zeilen}}
<li>
<a href="{{$.ActionBase}}/{{.Typ}}">
<strong>{{.Label}}</strong>
{{if .IstUebersteuert}}<span class="status status-published">eigene Vorlage</span>{{else}}<span class="status status-mittel">Plattform-Standard</span>{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -53,30 +53,39 @@
{{else if .CanEntscheiden}}
<h2>Entscheiden</h2>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/faelle/{{.ID}}/entscheiden">
<fieldset>
<legend>Entscheidung</legend>
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
</fieldset>
{{if .WerkzeugOptionen}}
<label>Werkzeug (bei Genehmigung erforderlich)
<select name="werkzeug_id">
<option value="">— auswählen —</option>
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
{{end}}
<label>Begründung (Pflicht bei Abweichung vom Vorschlag)
<textarea name="begruendung" rows="3"></textarea>
</label>
<button type="submit">Entscheidung speichern</button>
<div class="form-section">
<div class="form-grid">
<div class="form-full">
<label>Entscheidung</label>
<div class="chip-group">
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
</div>
</div>
{{if .WerkzeugOptionen}}
<div class="form-full">
<label for="werkzeug_id">Werkzeug (bei Genehmigung erforderlich)</label>
<select id="werkzeug_id" name="werkzeug_id">
<option value="">— auswählen —</option>
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
{{end}}
<div class="form-full">
<label for="begruendung">Begründung (Pflicht bei Abweichung vom Vorschlag)</label>
<textarea id="begruendung" name="begruendung" rows="3"></textarea>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Entscheidung speichern</button>
</div>
</form>
</div>
{{else}}
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
{{end}}

View File

@@ -0,0 +1,63 @@
{{define "firmendaten"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page-header">
<div>
<h1>Firmendaten</h1>
<p class="hinweis">Adress- und Abrechnungsdaten deiner Firma.</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/firma">
<div class="form-section">
<h3 class="form-section-title">Firma</h3>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" value="{{.Name}}" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" value="{{.Strasse}}" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" value="{{.PLZ}}" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" value="{{.Ort}}" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="{{.Land}}" required>
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Abrechnung</h3>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" value="{{.Rechnungsemail}}" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" value="{{.UStID}}" placeholder="z. B. DE123456789">
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -28,8 +28,8 @@
{{else}}
<div class="form-card">
<form method="post" action="/verwaltung/freigabe-regeln">
<fieldset class="form-section">
<legend>Neue Freigabe-Regel</legend>
<div class="form-section">
<h3 class="form-section-title">Neue Freigabe-Regel</h3>
<div class="form-grid">
<div>
<label for="bedingung">Bedingung</label>
@@ -55,7 +55,7 @@
</select>
</div>
</div>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>

View File

@@ -20,8 +20,8 @@
<div class="form-card">
<form method="post" action="/verwaltung/genehmiger-rollen">
<fieldset class="form-section">
<legend>Neue Genehmiger-Rolle</legend>
<div class="form-section">
<h3 class="form-section-title">Neue Genehmiger-Rolle</h3>
<div class="form-grid">
<div>
<label for="name">Name</label>
@@ -32,7 +32,7 @@
<input type="text" id="beschreibung" name="beschreibung" placeholder="z. B. Prüft den Antrag aus Sicherheitssicht">
</div>
</div>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Anlegen</button>
</div>

View File

@@ -28,6 +28,7 @@
{{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/firma">Firmendaten</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}}
@@ -35,6 +36,7 @@
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/email-vorlagen">E-Mail-Vorlagen</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout">
<button type="submit">Abmelden</button>

View File

@@ -21,8 +21,8 @@
{{if .Gespeichert}}<p class="hinweis">Löschfristen gespeichert.</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/loeschfristen">
<fieldset class="form-section">
<legend>Maximale Aufbewahrungsdauer (Tage)</legend>
<div class="form-section">
<h3 class="form-section-title">Maximale Aufbewahrungsdauer (Tage)</h3>
<div class="form-grid">
{{range .Zeilen}}
<div>
@@ -31,7 +31,7 @@
</div>
{{end}}
</div>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>

View File

@@ -6,15 +6,26 @@
<h1>Anmelden</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
<div class="form-card">
<form method="post" action="/login">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
<button type="submit">Anmelden</button>
<div class="form-section">
<h3 class="form-section-title">Anmelden</h3>
<div class="form-grid">
<div class="form-full">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-full">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Anmelden</button>
</div>
</form>
</div>
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
</div>

View File

@@ -15,13 +15,22 @@
geschlossen wird.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
<button type="submit">Importieren</button>
<div class="form-section">
<div class="form-grid">
<div class="form-full">
<label for="csv">CSV-Datei</label>
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Importieren</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -11,23 +11,34 @@
auf einem anderen Weg an die neue Person weitergegeben werden.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/nutzer">
<label for="email">E-Mail</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>
<fieldset>
<legend>Rolle</legend>
{{range .Rollen}}
<label><input type="radio" name="role" value="{{.}}" required> {{.}}</label>
{{end}}
</fieldset>
<button type="submit">Nutzer anlegen</button>
<div class="form-section">
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
<div class="form-full">
<label>Rolle</label>
<div class="chip-group">
{{range .Rollen}}
<label><input type="radio" name="role" value="{{.}}" required> {{.}}</label>
{{end}}
</div>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Nutzer anlegen</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -6,11 +6,21 @@
<h1>Passwort vergessen</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
<div class="form-card">
<form method="post" action="/passwort-vergessen">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<button type="submit">Link anfordern</button>
<div class="form-section">
<div class="form-grid">
<div class="form-full">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Link anfordern</button>
</div>
</form>
</div>
<p><a href="/login">Zurück zur Anmeldung</a></p>
</div>
</body>

View File

@@ -5,12 +5,22 @@
<div class="page">
<h1>Neues Passwort setzen</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/passwort-zuruecksetzen/{{.Token}}">
<label for="password">Neues Passwort</label>
<input type="password" id="password" name="password" required minlength="8">
<button type="submit">Passwort setzen</button>
<div class="form-section">
<div class="form-grid">
<div class="form-full">
<label for="password">Neues Passwort</label>
<input type="password" id="password" name="password" required minlength="8">
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Passwort setzen</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -9,18 +9,64 @@
den Betreiber freigeschaltet.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/register">
<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">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Konto anlegen</button>
<div class="form-section">
<h3 class="form-section-title">Firma</h3>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="Deutschland" required>
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Abrechnung</h3>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" placeholder="z. B. DE123456789">
</div>
</div>
</div>
<div class="form-section">
<h3 class="form-section-title">Erster Nutzer (wird Admin)</h3>
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit">Konto anlegen</button>
</div>
</form>
</div>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>