Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe68b16c9 | ||
|
|
7db4707bc5 | ||
|
|
fa5e68c892 | ||
|
|
d3b171f721 |
123
CLAUDE.md
123
CLAUDE.md
@@ -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)
|
## Row-Level-Security (2026-09-01, Migration 0021)
|
||||||
|
|
||||||
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
|
**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
|
für eine einheitliche Überschriften-Hierarchie statt Zufallsgrößen je
|
||||||
nach UA-Stylesheet.
|
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)
|
**enconf-Patterns übertragen (2026-08-31):** enconf (Schwesterprojekt)
|
||||||
ist React + Ant Design (`ProTable`/`StatCard`/`PageHeader`-Komponenten)
|
ist React + Ant Design (`ProTable`/`StatCard`/`PageHeader`-Komponenten)
|
||||||
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die
|
— nicht 1:1 übernehmbar, da Deklarix bewusst kein SPA ist, aber die
|
||||||
|
|||||||
@@ -13,30 +13,56 @@ import (
|
|||||||
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
|
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
|
||||||
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
|
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
|
||||||
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
|
// "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 {
|
type Account struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
EinladungToken string
|
EinladungToken string
|
||||||
|
Strasse string
|
||||||
|
PLZ string
|
||||||
|
Ort string
|
||||||
|
Land string
|
||||||
|
UStID string
|
||||||
|
Rechnungsemail string
|
||||||
CreatedAt time.Time
|
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 {
|
func scanAccount(row interface {
|
||||||
Scan(dest ...any) error
|
Scan(dest ...any) error
|
||||||
}) (Account, error) {
|
}) (Account, error) {
|
||||||
var a Account
|
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
|
return a, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
// 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, `
|
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,
|
RETURNING `+accountColumns,
|
||||||
name,
|
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
|
||||||
)
|
)
|
||||||
a, err := scanAccount(row)
|
a, err := scanAccount(row)
|
||||||
if err != nil {
|
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
|
// 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) {
|
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)
|
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||||
a, err := scanAccount(row)
|
a, err := scanAccount(row)
|
||||||
@@ -59,6 +86,26 @@ func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, er
|
|||||||
return a, nil
|
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.
|
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
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)
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package store_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||||
@@ -37,7 +39,7 @@ func TestListAccounts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListAccounts: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) {
|
|||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
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) {
|
func TestUpdateAccount(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -66,7 +119,7 @@ func TestUpdateAccountNotFound(t *testing.T) {
|
|||||||
func TestGetAccountByEinladungToken(t *testing.T) {
|
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -88,7 +141,7 @@ func TestGetAccountByEinladungToken(t *testing.T) {
|
|||||||
func TestRegenerateEinladungToken(t *testing.T) {
|
func TestRegenerateEinladungToken(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
116
internal/store/email_vorlage.go
Normal file
116
internal/store/email_vorlage.go
Normal 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
|
||||||
|
}
|
||||||
117
internal/store/email_vorlage_test.go
Normal file
117
internal/store/email_vorlage_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal 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;
|
||||||
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal file
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal 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 '';
|
||||||
@@ -36,7 +36,7 @@ func openTestStore(t *testing.T) *store.Store {
|
|||||||
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||||
func testAccountID(t *testing.T, s *store.Store) string {
|
func testAccountID(t *testing.T, s *store.Store) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
acc, err := s.CreateAccount(context.Background(), store.AccountInput{Name: "Test-Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type authPageData struct {
|
type authPageData struct {
|
||||||
@@ -46,11 +47,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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")
|
email := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
if accountName == "" || email == "" || password == "" {
|
if accountInput.Name == "" || email == "" || password == "" ||
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +79,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
// RLS-geschützt) von der Policy abgelehnt.
|
// RLS-geschützt) von der Policy abgelehnt.
|
||||||
var errMsg string
|
var errMsg string
|
||||||
err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error {
|
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 {
|
if err != nil {
|
||||||
errMsg = "Konto konnte nicht angelegt werden"
|
errMsg = "Konto konnte nicht angelegt werden"
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||||
@@ -13,9 +15,9 @@ func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
|||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
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"},
|
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
})
|
}, firmenPflichtfelder()))
|
||||||
if resp.Code != http.StatusSeeOther {
|
if resp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,7 @@ func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
|
|||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,11 +111,21 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req
|
|||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
return
|
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")
|
email := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
if accountName == "" || email == "" || password == "" {
|
if accountInput.Name == "" || email == "" || password == "" ||
|
||||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
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 {
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
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()
|
ctx := r.Context()
|
||||||
acc, err := s.store.CreateAccount(ctx, accountName)
|
acc, err := s.store.CreateAccount(ctx, accountInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
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 {
|
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
|
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)
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
|||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEinladungFormZeigtFirmenname(t *testing.T) {
|
func TestEinladungFormZeigtFirmenname(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,7 @@ func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
|
|||||||
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
|
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
207
internal/web/email_vorlage_handlers.go
Normal file
207
internal/web/email_vorlage_handlers.go
Normal 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)
|
||||||
|
}
|
||||||
113
internal/web/email_vorlage_handlers_test.go
Normal file
113
internal/web/email_vorlage_handlers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
|
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Fachebene-Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testi
|
|||||||
|
|
||||||
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mit Katalog"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func TestFallDetailRejectsForeignAccount(t *testing.T) {
|
|||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
74
internal/web/firmendaten_handlers.go
Normal file
74
internal/web/firmendaten_handlers.go
Normal 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)
|
||||||
|
}
|
||||||
87
internal/web/firmendaten_handlers_test.go
Normal file
87
internal/web/firmendaten_handlers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"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) {
|
func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
acc, err := fs.CreateAccount(ctx, "Freigabe-Mandant")
|
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Freigabe-Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,16 @@ func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request)
|
|||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
|
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
|
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
|
||||||
// (keine Enumeration über unterschiedliches Verhalten bei
|
// (keine Enumeration über unterschiedliches Verhalten bei
|
||||||
// Versandfehlern), aber serverseitig geloggt statt
|
// Versandfehlern), aber serverseitig geloggt statt
|
||||||
// verschluckt — sonst bleibt ein dauerhaft falsch
|
// verschluckt — sonst bleibt ein dauerhaft falsch
|
||||||
// konfigurierter SMTP-Server unbemerkt.
|
// 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)
|
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) {
|
|||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s, fm := newServerWithMailer(t, fs)
|
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"},
|
"account_name": {"Reset-Firma"}, "email": {"reset@example.com"},
|
||||||
"password": {"altes-passwort"},
|
"password": {"altes-passwort"},
|
||||||
})
|
}, firmenPflichtfelder()))
|
||||||
if regResp.Code != http.StatusSeeOther {
|
if regResp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("register status = %d, want 303", regResp.Code)
|
t.Fatalf("register status = %d, want 303", regResp.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// einen tatsächlichen Registereintrag brauchen.
|
// einen tatsächlichen Registereintrag brauchen.
|
||||||
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
|
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
acc, err := fs.CreateAccount(context.Background(), "Register-Mandant")
|
acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Register-Mandant"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ type Store interface {
|
|||||||
WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error
|
WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error
|
||||||
SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) 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)
|
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)
|
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||||
@@ -52,6 +53,10 @@ type Store interface {
|
|||||||
SetUserPassword(ctx context.Context, id, passwordHash string) error
|
SetUserPassword(ctx context.Context, id, passwordHash string) error
|
||||||
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
|
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
|
||||||
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]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)
|
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
|
||||||
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
|
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
|
||||||
MarkPasswordResetTokenUsed(ctx context.Context, id string) 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("POST /login", s.handleLogin)
|
||||||
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
|
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
|
||||||
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
|
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("GET /passwort-vergessen", s.handlePasswortVergessenForm)
|
||||||
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
||||||
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
||||||
|
|||||||
@@ -45,7 +45,18 @@ type fakeStore struct {
|
|||||||
freigabeRegeln map[string]store.FreigabeRegel
|
freigabeRegeln map[string]store.FreigabeRegel
|
||||||
freigabeschritte map[string]store.Freigabeschritt
|
freigabeschritte map[string]store.Freigabeschritt
|
||||||
passwordResetTokens map[string]store.PasswordResetToken
|
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 {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -67,6 +78,16 @@ func newFakeStore() *fakeStore {
|
|||||||
freigabeschritte: map[string]store.Freigabeschritt{},
|
freigabeschritte: map[string]store.Freigabeschritt{},
|
||||||
passwordResetTokens: map[string]store.PasswordResetToken{},
|
passwordResetTokens: map[string]store.PasswordResetToken{},
|
||||||
loeschfristen: map[string]map[string]int{},
|
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
|
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 {
|
func (f *fakeStore) newID() string {
|
||||||
f.nextID++
|
f.nextID++
|
||||||
return fmt.Sprintf("id-%d", 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()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
id := f.newID()
|
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
|
f.accounts[acc.ID] = acc
|
||||||
return acc, nil
|
return acc, nil
|
||||||
}
|
}
|
||||||
@@ -107,6 +174,19 @@ func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.A
|
|||||||
return acc, nil
|
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) {
|
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
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 {
|
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
acc, err := fs.CreateAccount(ctx, accountName)
|
acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: accountName})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -947,6 +1027,28 @@ func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
|
|||||||
return s, fs, 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 {
|
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
@@ -1008,10 +1110,10 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
|||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
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"},
|
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
|
||||||
"password": {"ein-sicheres-passwort"},
|
"password": {"ein-sicheres-passwort"},
|
||||||
})
|
}, firmenPflichtfelder()))
|
||||||
if regResp.Code != http.StatusSeeOther {
|
if regResp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
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()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
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"},
|
"account_name": {"Meine Firma"}, "email": {"neu2@example.com"},
|
||||||
"password": {"ein-sicheres-passwort"},
|
"password": {"ein-sicheres-passwort"},
|
||||||
})
|
}, firmenPflichtfelder()))
|
||||||
if regResp.Code != http.StatusSeeOther {
|
if regResp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
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) {
|
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fs)
|
s := newServer(t, fs)
|
||||||
form := url.Values{
|
form := mergeValues(url.Values{
|
||||||
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
||||||
"password": {"ein-sicheres-passwort"},
|
"password": {"ein-sicheres-passwort"},
|
||||||
}
|
}, firmenPflichtfelder())
|
||||||
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("first register status = %d, want 303", resp.Code)
|
t.Fatalf("first register status = %d, want 303", resp.Code)
|
||||||
}
|
}
|
||||||
@@ -1107,7 +1209,7 @@ func TestLoginWithCorrectPassword(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1131,7 +1233,7 @@ func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1155,7 +1257,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
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 {
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -582,6 +582,16 @@ button.entfernen {
|
|||||||
margin-bottom: 0;
|
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 {
|
.form-section {
|
||||||
border: none;
|
border: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -593,16 +603,11 @@ button.entfernen {
|
|||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* <legend> ignoriert das padding des umschließenden <fieldset> (es
|
.form-section-title {
|
||||||
positioniert sich an dessen Border-Box, nicht an der Padding-Box) —
|
margin: 0 0 16px 0;
|
||||||
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;
|
|
||||||
font-size: 1.0625rem;
|
font-size: 1.0625rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
|
|||||||
@@ -25,12 +25,22 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/verwaltung/abteilungen">
|
<form method="post" action="/verwaltung/abteilungen">
|
||||||
<label for="name">Neue Abteilung</label>
|
<div class="form-section">
|
||||||
<input type="text" id="name" name="name" required>
|
<div class="form-grid">
|
||||||
<button type="submit">Anlegen</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -8,11 +8,21 @@
|
|||||||
<h1>{{.Name}}</h1>
|
<h1>{{.Name}}</h1>
|
||||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
|
<form method="post" action="/betreiber/accounts/{{.AccountID}}/umbenennen">
|
||||||
<label for="name">Firmenname</label>
|
<div class="form-section">
|
||||||
<input type="text" id="name" name="name" value="{{.Name}}" required>
|
<div class="form-grid">
|
||||||
<button type="submit">Umbenennen</button>
|
<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>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Nutzer</h2>
|
<h2>Nutzer</h2>
|
||||||
<ul class="beteiligte">
|
<ul class="beteiligte">
|
||||||
|
|||||||
@@ -11,19 +11,65 @@
|
|||||||
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
|
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/betreiber/accounts">
|
<form method="post" action="/betreiber/accounts">
|
||||||
<label for="account_name">Firmenname</label>
|
<div class="form-section">
|
||||||
<input type="text" id="account_name" name="account_name" required>
|
<h3 class="form-section-title">Firma</h3>
|
||||||
|
<div class="form-grid">
|
||||||
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
<div class="form-full">
|
||||||
<input type="email" id="email" name="email" required>
|
<label for="account_name">Firmenname</label>
|
||||||
|
<input type="text" id="account_name" name="account_name" required>
|
||||||
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
</div>
|
||||||
<input type="password" id="password" name="password" minlength="8" required>
|
<div class="form-full">
|
||||||
|
<label for="strasse">Straße und Hausnummer</label>
|
||||||
<button type="submit">Firma anlegen</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<span class="stat-card-label">Audit-Log (neueste)</span>
|
<span class="stat-card-label">Audit-Log (neueste)</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li><a class="stat-card stat-card-action" href="/betreiber/email-vorlagen"><span class="stat-card-label">E-Mail-Vorlagen →</span></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
<form method="post" action="{{if .ID}}{{.ActionBase}}/{{.ID}}{{else}}{{.ActionBase}}{{end}}">
|
||||||
<fieldset class="form-section">
|
<div class="form-section">
|
||||||
<legend>Grunddaten</legend>
|
<h3 class="form-section-title">Grunddaten</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="name">Name</label>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
|
|
||||||
<fieldset class="form-section">
|
<div class="form-section">
|
||||||
<legend>Rechtliches & Datenschutz</legend>
|
<h3 class="form-section-title">Rechtliches & Datenschutz</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-full">
|
<div class="form-full">
|
||||||
<label>Zusicherungen</label>
|
<label>Zusicherungen</label>
|
||||||
@@ -66,10 +66,10 @@
|
|||||||
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
|
<input type="text" id="subprozessoren" name="subprozessoren" value="{{.Subprozessoren}}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
|
|
||||||
<fieldset class="form-section">
|
<div class="form-section">
|
||||||
<legend>Einsatzbereich</legend>
|
<h3 class="form-section-title">Einsatzbereich</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-full">
|
<div class="form-full">
|
||||||
<label>Geeignete Zwecke</label>
|
<label>Geeignete Zwecke</label>
|
||||||
@@ -84,10 +84,10 @@
|
|||||||
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
<input type="text" id="einschraenkungen" name="einschraenkungen" value="{{.Einschraenkungen}}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
|
|
||||||
<fieldset class="form-section">
|
<div class="form-section">
|
||||||
<legend>Beleg</legend>
|
<h3 class="form-section-title">Beleg</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="letzte_pruefung">Letzte Prüfung</label>
|
<label for="letzte_pruefung">Letzte Prüfung</label>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
<input type="text" id="quelle" name="quelle" value="{{.Quelle}}" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
|
|||||||
@@ -6,15 +6,25 @@
|
|||||||
<h1>Einladung annehmen</h1>
|
<h1>Einladung annehmen</h1>
|
||||||
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
|
<p class="hinweis">Sie treten dem Konto von <strong>{{.Firmenname}}</strong> bei.</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="email">E-Mail</label>
|
<div class="form-section">
|
||||||
<input type="email" id="email" name="email" required>
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
<label for="email">E-Mail</label>
|
||||||
<input type="password" id="password" name="password" minlength="8" required>
|
<input type="email" id="email" name="email" required>
|
||||||
|
</div>
|
||||||
<button type="submit">Konto anlegen</button>
|
<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>
|
</form>
|
||||||
|
</div>
|
||||||
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
49
internal/web/templates/email_vorlage_bearbeiten.html
Normal file
49
internal/web/templates/email_vorlage_bearbeiten.html
Normal 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}}">← 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}}
|
||||||
30
internal/web/templates/email_vorlagen_liste.html
Normal file
30
internal/web/templates/email_vorlagen_liste.html
Normal 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}}
|
||||||
@@ -53,30 +53,39 @@
|
|||||||
{{else if .CanEntscheiden}}
|
{{else if .CanEntscheiden}}
|
||||||
<h2>Entscheiden</h2>
|
<h2>Entscheiden</h2>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/faelle/{{.ID}}/entscheiden">
|
<form method="post" action="/faelle/{{.ID}}/entscheiden">
|
||||||
<fieldset>
|
<div class="form-section">
|
||||||
<legend>Entscheidung</legend>
|
<div class="form-grid">
|
||||||
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
|
<div class="form-full">
|
||||||
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
|
<label>Entscheidung</label>
|
||||||
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
|
<div class="chip-group">
|
||||||
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
|
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
|
||||||
</fieldset>
|
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
|
||||||
|
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
|
||||||
{{if .WerkzeugOptionen}}
|
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
|
||||||
<label>Werkzeug (bei Genehmigung erforderlich)
|
</div>
|
||||||
<select name="werkzeug_id">
|
</div>
|
||||||
<option value="">— auswählen —</option>
|
{{if .WerkzeugOptionen}}
|
||||||
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
<div class="form-full">
|
||||||
</select>
|
<label for="werkzeug_id">Werkzeug (bei Genehmigung erforderlich)</label>
|
||||||
</label>
|
<select id="werkzeug_id" name="werkzeug_id">
|
||||||
{{end}}
|
<option value="">— auswählen —</option>
|
||||||
|
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
<label>Begründung (Pflicht bei Abweichung vom Vorschlag)
|
</select>
|
||||||
<textarea name="begruendung" rows="3"></textarea>
|
</div>
|
||||||
</label>
|
{{end}}
|
||||||
|
<div class="form-full">
|
||||||
<button type="submit">Entscheidung speichern</button>
|
<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>
|
</form>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
|
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
63
internal/web/templates/firmendaten.html
Normal file
63
internal/web/templates/firmendaten.html
Normal 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}}
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="/verwaltung/freigabe-regeln">
|
<form method="post" action="/verwaltung/freigabe-regeln">
|
||||||
<fieldset class="form-section">
|
<div class="form-section">
|
||||||
<legend>Neue Freigabe-Regel</legend>
|
<h3 class="form-section-title">Neue Freigabe-Regel</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="bedingung">Bedingung</label>
|
<label for="bedingung">Bedingung</label>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Anlegen</button>
|
<button type="submit">Anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="/verwaltung/genehmiger-rollen">
|
<form method="post" action="/verwaltung/genehmiger-rollen">
|
||||||
<fieldset class="form-section">
|
<div class="form-section">
|
||||||
<legend>Neue Genehmiger-Rolle</legend>
|
<h3 class="form-section-title">Neue Genehmiger-Rolle</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div>
|
<div>
|
||||||
<label for="name">Name</label>
|
<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">
|
<input type="text" id="beschreibung" name="beschreibung" placeholder="z. B. Prüft den Antrag aus Sicherheitssicht">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Anlegen</button>
|
<button type="submit">Anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||||
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||||
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</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/nutzer">Nutzerverwaltung</a>{{end}}
|
||||||
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
||||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
|
{{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/freigabe-regeln">Freigabe-Regeln</a>{{end}}
|
||||||
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</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}}
|
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button type="submit">Abmelden</button>
|
<button type="submit">Abmelden</button>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
{{if .Gespeichert}}<p class="hinweis">Löschfristen gespeichert.</p>{{end}}
|
{{if .Gespeichert}}<p class="hinweis">Löschfristen gespeichert.</p>{{end}}
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="/verwaltung/loeschfristen">
|
<form method="post" action="/verwaltung/loeschfristen">
|
||||||
<fieldset class="form-section">
|
<div class="form-section">
|
||||||
<legend>Maximale Aufbewahrungsdauer (Tage)</legend>
|
<h3 class="form-section-title">Maximale Aufbewahrungsdauer (Tage)</h3>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{range .Zeilen}}
|
{{range .Zeilen}}
|
||||||
<div>
|
<div>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,15 +6,26 @@
|
|||||||
<h1>Anmelden</h1>
|
<h1>Anmelden</h1>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/login">
|
<form method="post" action="/login">
|
||||||
<label for="email">E-Mail</label>
|
<div class="form-section">
|
||||||
<input type="email" id="email" name="email" required>
|
<h3 class="form-section-title">Anmelden</h3>
|
||||||
|
<div class="form-grid">
|
||||||
<label for="password">Passwort</label>
|
<div class="form-full">
|
||||||
<input type="password" id="password" name="password" required>
|
<label for="email">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
<button type="submit">Anmelden</button>
|
</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>
|
</form>
|
||||||
|
</div>
|
||||||
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||||
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
|
<p><a href="/passwort-vergessen">Passwort vergessen?</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,13 +15,22 @@
|
|||||||
geschlossen wird.
|
geschlossen wird.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
|
<form method="post" action="/verwaltung/nutzer/import" enctype="multipart/form-data">
|
||||||
<label for="csv">CSV-Datei</label>
|
<div class="form-section">
|
||||||
<input type="file" id="csv" name="csv" accept=".csv,text/csv" required>
|
<div class="form-grid">
|
||||||
|
<div class="form-full">
|
||||||
<button type="submit">Importieren</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -11,23 +11,34 @@
|
|||||||
auf einem anderen Weg an die neue Person weitergegeben werden.
|
auf einem anderen Weg an die neue Person weitergegeben werden.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/verwaltung/nutzer">
|
<form method="post" action="/verwaltung/nutzer">
|
||||||
<label for="email">E-Mail</label>
|
<div class="form-section">
|
||||||
<input type="email" id="email" name="email" required>
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
<label for="email">E-Mail</label>
|
||||||
<input type="password" id="password" name="password" minlength="8" required>
|
<input type="email" id="email" name="email" required>
|
||||||
|
</div>
|
||||||
<fieldset>
|
<div>
|
||||||
<legend>Rolle</legend>
|
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
||||||
{{range .Rollen}}
|
<input type="password" id="password" name="password" minlength="8" required>
|
||||||
<label><input type="radio" name="role" value="{{.}}" required> {{.}}</label>
|
</div>
|
||||||
{{end}}
|
<div class="form-full">
|
||||||
</fieldset>
|
<label>Rolle</label>
|
||||||
|
<div class="chip-group">
|
||||||
<button type="submit">Nutzer anlegen</button>
|
{{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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,11 +6,21 @@
|
|||||||
<h1>Passwort vergessen</h1>
|
<h1>Passwort vergessen</h1>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
{{if .Message}}<p class="hinweis">{{.Message}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/passwort-vergessen">
|
<form method="post" action="/passwort-vergessen">
|
||||||
<label for="email">E-Mail</label>
|
<div class="form-section">
|
||||||
<input type="email" id="email" name="email" required>
|
<div class="form-grid">
|
||||||
<button type="submit">Link anfordern</button>
|
<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>
|
</form>
|
||||||
|
</div>
|
||||||
<p><a href="/login">Zurück zur Anmeldung</a></p>
|
<p><a href="/login">Zurück zur Anmeldung</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,12 +5,22 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Neues Passwort setzen</h1>
|
<h1>Neues Passwort setzen</h1>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/passwort-zuruecksetzen/{{.Token}}">
|
<form method="post" action="/passwort-zuruecksetzen/{{.Token}}">
|
||||||
<label for="password">Neues Passwort</label>
|
<div class="form-section">
|
||||||
<input type="password" id="password" name="password" required minlength="8">
|
<div class="form-grid">
|
||||||
<button type="submit">Passwort setzen</button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,18 +9,64 @@
|
|||||||
den Betreiber freigeschaltet.
|
den Betreiber freigeschaltet.
|
||||||
</p>
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<div class="form-card">
|
||||||
<form method="post" action="/register">
|
<form method="post" action="/register">
|
||||||
<label for="account_name">Firmenname</label>
|
<div class="form-section">
|
||||||
<input type="text" id="account_name" name="account_name" required>
|
<h3 class="form-section-title">Firma</h3>
|
||||||
|
<div class="form-grid">
|
||||||
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
<div class="form-full">
|
||||||
<input type="email" id="email" name="email" required>
|
<label for="account_name">Firmenname</label>
|
||||||
|
<input type="text" id="account_name" name="account_name" required>
|
||||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
</div>
|
||||||
<input type="password" id="password" name="password" minlength="8" required>
|
<div class="form-full">
|
||||||
|
<label for="strasse">Straße und Hausnummer</label>
|
||||||
<button type="submit">Konto anlegen</button>
|
<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>
|
</form>
|
||||||
|
</div>
|
||||||
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user