Compare commits

...

3 Commits

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:10:46 +02:00
42 changed files with 1038 additions and 283 deletions

View File

@@ -640,9 +640,15 @@ 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 durch den Mandanten selbst; für E-Mails, die die Plattform an
Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter
Standard (`GET/POST /betreiber/email-vorlagen`, nur Betreiber), gesetzt Standard (`/betreiber/email-vorlagen`, nur Betreiber), gesetzt =
= mandantenspezifische Übersteuerung (`GET/POST /verwaltung/email- mandantenspezifische Übersteuerung (`/verwaltung/email-vorlagen`, nur
vorlagen`, nur Admin). `ResolveEmailVorlage` liefert die eigene 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 Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
Mandant, der nie etwas anpasst, bekommt automatisch jede künftige Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
Änderung des Plattform-Standards. `emailVorlageTypen` in Änderung des Plattform-Standards. `emailVorlageTypen` in
@@ -673,6 +679,45 @@ Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
--- ---
## 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
@@ -1006,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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ func openTestStore(t *testing.T) *store.Store {
// testAccountID legt einen Mandanten an und liefert dessen ID. // 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)
} }

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,10 @@
// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich // (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die // übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
// feste, bekannte Liste der vom System versendeten E-Mails — aktuell // feste, bekannte Liste der vom System versendeten E-Mails — aktuell
// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt. // 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 package web
import ( import (
@@ -28,13 +31,13 @@ var emailVorlageTypen = []emailVorlageTypInfo{
}, },
} }
func istGueltigerEmailVorlageTyp(typ string) bool { func findEmailVorlageTyp(typ string) (emailVorlageTypInfo, bool) {
for _, t := range emailVorlageTypen { for _, t := range emailVorlageTypen {
if t.Typ == typ { if t.Typ == typ {
return true return t, true
} }
} }
return false return emailVorlageTypInfo{}, false
} }
// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer // renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
@@ -48,54 +51,74 @@ func renderEmailVorlage(text, link string) string {
type emailVorlageZeile struct { type emailVorlageZeile struct {
Typ string Typ string
Label string Label string
Platzhalter string
Betreff string
Text string
IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
} }
type emailVorlagenListeData struct { type emailVorlagenListeData struct {
Title string Title string
Nav navData Nav navData
Zeilen []emailVorlageZeile Zeilen []emailVorlageZeile
Error string ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
Gespeichert bool
ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
} }
func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID accountID := currentUser(r).AccountID
data := emailVorlagenListeData{ data := emailVorlagenListeData{Title: "E-Mail-Vorlagen", Nav: navFor(r), ActionBase: "/verwaltung/email-vorlagen"}
Title: "E-Mail-Vorlagen", Nav: navFor(r),
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
ActionBase: "/verwaltung/email-vorlagen",
}
for _, t := range emailVorlageTypen { for _, t := range emailVorlageTypen {
resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, t.Typ) _, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
if err != nil { data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label, IstUebersteuert: err == nil})
http.Error(w, "E-Mail-Vorlagen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
eigene, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
istUebersteuert := err == nil && eigene.ID == resolved.ID
data.Zeilen = append(data.Zeilen, emailVorlageZeile{
Typ: t.Typ, Label: t.Label, Platzhalter: t.Platzhalter,
Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: istUebersteuert,
})
} }
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil { if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
} }
func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) { type emailVorlageBearbeitenData struct {
if err := r.ParseForm(); err != nil { Title string
http.Error(w, "ungültiges Formular", http.StatusBadRequest) 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 return
} }
typ := r.FormValue("typ") accountID := currentUser(r).AccountID
if !istGueltigerEmailVorlageTyp(typ) { resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, typ)
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest) 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 return
} }
accountID := currentUser(r).AccountID accountID := currentUser(r).AccountID
@@ -109,17 +132,13 @@ func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Requ
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther) http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
} }
func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) { func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { typ := r.PathValue("typ")
http.Error(w, "ungültiges Formular", http.StatusBadRequest) if _, ok := findEmailVorlageTyp(typ); !ok {
return http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
}
typ := r.FormValue("typ")
if !istGueltigerEmailVorlageTyp(typ) {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest)
return return
} }
accountID := currentUser(r).AccountID accountID := currentUser(r).AccountID
@@ -127,41 +146,51 @@ func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.
http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/verwaltung/email-vorlagen?gespeichert=1", http.StatusSeeOther) http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
} }
// ─── Betreiber (Ebene 5): plattformweiter Standard ───────────────────── // ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
data := emailVorlagenListeData{ data := emailVorlagenListeData{Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r), ActionBase: "/betreiber/email-vorlagen"}
Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r),
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
ActionBase: "/betreiber/email-vorlagen",
}
for _, t := range emailVorlageTypen { for _, t := range emailVorlageTypen {
v, err := s.store.GetEmailVorlage(r.Context(), nil, t.Typ) data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label})
if err != nil {
http.Error(w, "E-Mail-Vorlagen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data.Zeilen = append(data.Zeilen, emailVorlageZeile{
Typ: t.Typ, Label: t.Label, Platzhalter: t.Platzhalter,
Betreff: v.Betreff, Text: v.Text,
})
} }
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil { if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
} }
func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBetreiberEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { typ := r.PathValue("typ")
http.Error(w, "ungültiges Formular", http.StatusBadRequest) info, ok := findEmailVorlageTyp(typ)
if !ok {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
return return
} }
typ := r.FormValue("typ") v, err := s.store.GetEmailVorlage(r.Context(), nil, typ)
if !istGueltigerEmailVorlageTyp(typ) { if err != nil {
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusBadRequest) 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 return
} }
betreff := strings.TrimSpace(r.FormValue("betreff")) betreff := strings.TrimSpace(r.FormValue("betreff"))
@@ -174,5 +203,5 @@ func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, "/betreiber/email-vorlagen?gespeichert=1", http.StatusSeeOther) http.Redirect(w, r, "/betreiber/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
} }

View File

@@ -13,22 +13,30 @@ func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
s := newServer(t, fs) s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin") cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen") 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 { if getResp.Code != http.StatusOK {
t.Fatalf("GET status = %d, want 200", getResp.Code) t.Fatalf("GET Bearbeiten-Seite status = %d, want 200", getResp.Code)
} }
if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") { if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") {
t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String()) t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String())
} }
saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen", url.Values{ saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"typ": {"passwort_zuruecksetzen"}, "betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"}, "betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"},
}) })
if saveResp.Code != http.StatusSeeOther { if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String()) t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
} }
afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen") afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
body := afterResp.Body.String() body := afterResp.Body.String()
if !strings.Contains(body, "Mein eigener Betreff") { if !strings.Contains(body, "Mein eigener Betreff") {
t.Fatalf("gespeicherter Betreff fehlt, body: %s", body) t.Fatalf("gespeicherter Betreff fehlt, body: %s", body)
@@ -37,11 +45,11 @@ func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body) t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body)
} }
resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/zuruecksetzen", url.Values{"typ": {"passwort_zuruecksetzen"}}) resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen/zuruecksetzen", url.Values{})
if resetResp.Code != http.StatusSeeOther { if resetResp.Code != http.StatusSeeOther {
t.Fatalf("reset status = %d, want 303", resetResp.Code) t.Fatalf("reset status = %d, want 303", resetResp.Code)
} }
finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen") finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") { if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") {
t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv") t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv")
} }
@@ -52,14 +60,14 @@ func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) {
s := newServer(t, fs) s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber") cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber")
saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen", url.Values{ saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen", url.Values{
"typ": {"passwort_zuruecksetzen"}, "betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"}, "betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"},
}) })
if saveResp.Code != http.StatusSeeOther { if saveResp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String()) t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
} }
getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen") getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen")
if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") { if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") {
t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String()) t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String())
} }
@@ -81,8 +89,8 @@ func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) {
s, fm := newServerWithMailer(t, fs) s, fm := newServerWithMailer(t, fs)
cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin") cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin")
if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen", url.Values{ if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
"typ": {"passwort_zuruecksetzen"}, "betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."}, "betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."},
}); resp.Code != http.StatusSeeOther { }); resp.Code != http.StatusSeeOther {
t.Fatalf("save status = %d, want 303", resp.Code) t.Fatalf("save status = %d, want 303", resp.Code)
} }

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import (
"strings" "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)
} }

View File

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

View File

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

View File

@@ -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
@@ -166,11 +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", s.requireAdmin(s.handleEmailVorlagenListe))
mux.HandleFunc("POST /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlageSpeichern)) mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm))
mux.HandleFunc("POST /verwaltung/email-vorlagen/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen)) 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", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
mux.HandleFunc("POST /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern)) 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)

View File

@@ -149,11 +149,15 @@ func (f *fakeStore) newID() string {
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
} }
@@ -170,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()
@@ -950,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)
} }
@@ -1010,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()))
@@ -1071,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())
} }
@@ -1101,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())
} }
@@ -1147,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)
} }
@@ -1170,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)
} }
@@ -1194,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)
} }
@@ -1218,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)
} }

View File

@@ -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 {

View File

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

View File

@@ -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">

View File

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

View File

@@ -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 &amp; Datenschutz</legend> <h3 class="form-section-title">Rechtliches &amp; 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>

View File

@@ -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>

View File

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

View File

@@ -7,46 +7,22 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h1>{{.Title}}</h1> <h1>{{.Title}}</h1>
<p class="hinweis"> <p class="hinweis">Betreff und Text der vom System versendeten E-Mails.</p>
Betreff und Text der vom System versendeten E-Mails. Platzhalter
in geschweiften Klammern (z. B. <code>{{"{{link}}"}}</code>)
werden beim Versand automatisch ersetzt.
</p>
</div> </div>
</div> </div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}} {{if not .Zeilen}}
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}} <p class="hinweis">Es gibt noch keine vom System versendeten E-Mails.</p>
{{range .Zeilen}} {{else}}
<div class="form-card"> <ul class="beitraege-liste">
<form method="post" action="{{$.ActionBase}}"> {{range .Zeilen}}
<input type="hidden" name="typ" value="{{.Typ}}"> <li>
<fieldset class="form-section"> <a href="{{$.ActionBase}}/{{.Typ}}">
<legend>{{.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}}</legend> <strong>{{.Label}}</strong>
<p class="hinweis">Verfügbare Platzhalter: {{.Platzhalter}}</p> {{if .IstUebersteuert}}<span class="status status-published">eigene Vorlage</span>{{else}}<span class="status status-mittel">Plattform-Standard</span>{{end}}
<div class="form-grid"> </a>
<div class="form-full"> </li>
<label for="betreff-{{.Typ}}">Betreff</label> {{end}}
<input type="text" id="betreff-{{.Typ}}" name="betreff" value="{{.Betreff}}" required> </ul>
</div>
<div class="form-full">
<label for="text-{{.Typ}}">Text</label>
<textarea id="text-{{.Typ}}" name="text" rows="8" required>{{.Text}}</textarea>
</div>
</div>
</fieldset>
<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/zuruecksetzen" onsubmit="return confirm('Eigene Vorlage löschen und zum Plattform-Standard zurückkehren?')">
<input type="hidden" name="typ" value="{{.Typ}}">
<button type="submit" class="entfernen">Auf Plattform-Standard zurücksetzen</button>
</form>
</div>
{{end}}
</div>
{{end}} {{end}}
</div> </div>
</body> </body>

View File

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

View File

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

View File

@@ -28,8 +28,8 @@
{{else}} {{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>

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>