Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa5e68c892 | ||
|
|
d3b171f721 |
46
CLAUDE.md
46
CLAUDE.md
@@ -633,6 +633,52 @@ und live per curl gegen echten Server + Postgres verifiziert.
|
||||
|
||||
---
|
||||
|
||||
## E-Mail-Vorlagen (2026-09-01, Migration 0022)
|
||||
|
||||
Auf Nutzerwunsch: alle vom System versendeten E-Mails sollen editierbar
|
||||
sein — für E-Mails, die an die eigenen Mitarbeiter einer Firma gehen,
|
||||
durch den Mandanten selbst; für E-Mails, die die Plattform an
|
||||
Mandanten-Admins schickt, durch den Betreiber. Zweistufig wie der
|
||||
Werkzeugkatalog: `email_vorlage.account_id` NULL = plattformweiter
|
||||
Standard (`/betreiber/email-vorlagen`, nur Betreiber), gesetzt =
|
||||
mandantenspezifische Übersteuerung (`/verwaltung/email-vorlagen`, nur
|
||||
Admin) — jeweils ein eigener Nav-Punkt (nicht als weiterer Button auf
|
||||
einer bestehenden Seite), mit Liste + eigener Bearbeiten-Seite je Typ
|
||||
(`GET .../email-vorlagen` Liste, `GET/POST .../email-vorlagen/{typ}`
|
||||
Bearbeiten) statt eines einzigen, mit allen Formularen gestapelten
|
||||
Screens — auf ausdrücklichen Nutzerwunsch, da mit der Zeit weitere
|
||||
Benachrichtigungstypen dazukommen sollen und eine gestapelte Liste dann
|
||||
unübersichtlich würde. `ResolveEmailVorlage` liefert die eigene
|
||||
Übersteuerung, falls vorhanden, sonst den Plattform-Standard — ein
|
||||
Mandant, der nie etwas anpasst, bekommt automatisch jede künftige
|
||||
Änderung des Plattform-Standards. `emailVorlageTypen` in
|
||||
`internal/web/email_vorlage_handlers.go` ist die feste, bekannte Liste
|
||||
der vom System versendeten E-Mails — aktuell nur
|
||||
`passwort_zuruecksetzen` (die einzige E-Mail, die es bisher gibt),
|
||||
bewusst keine generische "beliebige E-Mail anlegen"-UI, da jeder Typ an
|
||||
eine echte Code-Stelle gebunden ist, die ihn tatsächlich versendet
|
||||
(Platzhalter wie `{{link}}` sind pro Typ verschieden und müssten sonst
|
||||
geraten werden).
|
||||
|
||||
**Bug beim Live-Verifizieren gefunden und behoben:** `UNIQUE
|
||||
(account_id, typ)` als einzelner Tabellen-Constraint reicht bei
|
||||
NULLABLE `account_id` NICHT — SQL behandelt NULL nie als gleich zu
|
||||
NULL, ein Mandant/Betreiber hätte also bei jedem Speichern eine neue
|
||||
Plattform-Standard-Zeile statt eines Updates bekommen (genau das ist
|
||||
beim ersten Testlauf passiert: zwei "Version 1"/"Version 2"-Zeilen
|
||||
gleichzeitig, `ResolveEmailVorlage` griff die falsche). Fix: zwei
|
||||
partielle Unique-Indizes (`... WHERE account_id IS NULL` /
|
||||
`... WHERE account_id IS NOT NULL`) statt eines gemeinsamen Constraints
|
||||
— `UpsertEmailVorlage` braucht dafür zwei unterschiedliche
|
||||
`ON CONFLICT`-Ziele (SQL erlaubt nur ein Ziel je INSERT-Anweisung), mit
|
||||
dediziertem Regressionstest (`TestUpsertPlattformStandardAktualisiertStattZuDuplizieren`)
|
||||
abgesichert. Live per curl mit echtem SMTP-Test-Server erneut
|
||||
verifiziert: zweimaliges Speichern des Plattform-Standards ergibt eine
|
||||
Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene
|
||||
Übersteuerung bekommt automatisch den zuletzt gesetzten Plattform-Text.
|
||||
|
||||
---
|
||||
|
||||
## Row-Level-Security (2026-09-01, Migration 0021)
|
||||
|
||||
**Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und
|
||||
|
||||
116
internal/store/email_vorlage.go
Normal file
116
internal/store/email_vorlage.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
||||
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
|
||||
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
|
||||
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type EmailVorlage struct {
|
||||
ID string
|
||||
AccountID *string
|
||||
Typ string
|
||||
Betreff string
|
||||
Text string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
|
||||
// accountID nil schreibt den plattformweiten Standard (nur für den
|
||||
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
|
||||
//
|
||||
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
|
||||
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
|
||||
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
|
||||
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
|
||||
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
|
||||
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
|
||||
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
var err error
|
||||
if accountID == nil {
|
||||
err = s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||
VALUES (NULL, $1, $2, $3)
|
||||
ON CONFLICT (typ) WHERE account_id IS NULL
|
||||
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
|
||||
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
} else {
|
||||
err = s.db(ctx).QueryRow(ctx, `
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
|
||||
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
|
||||
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
|
||||
// accountID nil sucht den plattformweiten Standard.
|
||||
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVorlage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
|
||||
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
|
||||
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
|
||||
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
|
||||
// Plattform-Standard wird per Migration angelegt).
|
||||
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
|
||||
var v EmailVorlage
|
||||
err := s.db(ctx).QueryRow(ctx, `
|
||||
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
|
||||
ORDER BY account_id NULLS LAST
|
||||
LIMIT 1
|
||||
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return EmailVorlage{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
|
||||
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
|
||||
// wieder auf den plattformweiten Standard zurück), beim Betreiber
|
||||
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
|
||||
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
|
||||
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
||||
tag, err := s.db(ctx).Exec(ctx, `
|
||||
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||
`, accountID, typ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete email vorlage: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
117
internal/store/email_vorlage_test.go
Normal file
117
internal/store/email_vorlage_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
|
||||
}
|
||||
if v.AccountID != nil {
|
||||
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
|
||||
}
|
||||
if v.Betreff == "" || v.Text == "" {
|
||||
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
|
||||
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
|
||||
}
|
||||
if resolved.AccountID != nil {
|
||||
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
|
||||
}
|
||||
|
||||
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
|
||||
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage: %v", err)
|
||||
}
|
||||
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
|
||||
}
|
||||
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
|
||||
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
|
||||
}
|
||||
|
||||
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
|
||||
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
|
||||
t.Fatalf("DeleteEmailVorlage: %v", err)
|
||||
}
|
||||
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
|
||||
}
|
||||
if resolved.AccountID != nil {
|
||||
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
|
||||
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
|
||||
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
|
||||
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
|
||||
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
|
||||
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage (1): %v", err)
|
||||
}
|
||||
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage (2): %v", err)
|
||||
}
|
||||
if first.ID != second.ID {
|
||||
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEmailVorlage: %v", err)
|
||||
}
|
||||
if got.Betreff != "Zweiter Betreff" {
|
||||
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
|
||||
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
|
||||
t.Fatalf("UpsertEmailVorlage A: %v", err)
|
||||
}
|
||||
|
||||
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEmailVorlage B: %v", err)
|
||||
}
|
||||
if resolvedB.Betreff == "Vorlage A" {
|
||||
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
|
||||
}
|
||||
|
||||
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
|
||||
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
|
||||
DROP TABLE email_vorlage;
|
||||
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
|
||||
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
|
||||
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
|
||||
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
|
||||
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
|
||||
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
|
||||
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
|
||||
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
|
||||
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
|
||||
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
|
||||
-- internal/web/email_vorlage_handlers.go).
|
||||
CREATE TABLE email_vorlage (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES account(id),
|
||||
typ TEXT NOT NULL,
|
||||
betreff TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
|
||||
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
|
||||
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
|
||||
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
|
||||
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
|
||||
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
|
||||
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
|
||||
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
|
||||
|
||||
-- Plattformweiter Standard für die einzige aktuell existierende
|
||||
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
|
||||
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
|
||||
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
|
||||
NULL,
|
||||
'passwort_zuruecksetzen',
|
||||
'Deklarix — Passwort zurücksetzen',
|
||||
'Hallo,' || E'\n\n' ||
|
||||
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
|
||||
'{{link}}' || E'\n\n' ||
|
||||
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
|
||||
);
|
||||
|
||||
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
|
||||
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
|
||||
-- account_id-Zeile anlegen/ändern.
|
||||
CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
|
||||
account_id IS NULL
|
||||
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
OR current_setting('app.is_betreiber', true) = 'true'
|
||||
) WITH CHECK (
|
||||
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||
);
|
||||
ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;
|
||||
207
internal/web/email_vorlage_handlers.go
Normal file
207
internal/web/email_vorlage_handlers.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
||||
// der Betreiber pflegt den plattformweiten Standard
|
||||
// (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
|
||||
// übersteuern (/verwaltung/email-vorlagen). emailVorlageTypen ist die
|
||||
// feste, bekannte Liste der vom System versendeten E-Mails — aktuell
|
||||
// nur Passwort-Zurücksetzen, die einzige E-Mail, die es bisher gibt,
|
||||
// aber bewusst als Liste-mit-Bearbeiten-Seite gebaut (wie Werkzeugkatalog/
|
||||
// Nutzerverwaltung), nicht als eine Seite mit allen Formularen gestapelt
|
||||
// — mit der Zeit kommen weitere Benachrichtigungstypen dazu.
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
type emailVorlageTypInfo struct {
|
||||
Typ string
|
||||
Label string
|
||||
Platzhalter string
|
||||
}
|
||||
|
||||
var emailVorlageTypen = []emailVorlageTypInfo{
|
||||
{
|
||||
Typ: "passwort_zuruecksetzen",
|
||||
Label: "Passwort zurücksetzen",
|
||||
Platzhalter: "{{link}} — der Zurücksetzen-Link, wird beim Versand eingesetzt.",
|
||||
},
|
||||
}
|
||||
|
||||
func findEmailVorlageTyp(typ string) (emailVorlageTypInfo, bool) {
|
||||
for _, t := range emailVorlageTypen {
|
||||
if t.Typ == typ {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return emailVorlageTypInfo{}, false
|
||||
}
|
||||
|
||||
// renderEmailVorlage ersetzt die bekannten Platzhalter im Text einer
|
||||
// Vorlage — aktuell nur {{link}} (Passwort-Zurücksetzen-Link).
|
||||
func renderEmailVorlage(text, link string) string {
|
||||
return strings.ReplaceAll(text, "{{link}}", link)
|
||||
}
|
||||
|
||||
// ─── Mandant (Ebene 4, admin-only): eigene Übersteuerung ───────────────
|
||||
|
||||
type emailVorlageZeile struct {
|
||||
Typ string
|
||||
Label string
|
||||
IstUebersteuert bool // eigene Vorlage vorhanden statt Plattform-Standard geerbt
|
||||
}
|
||||
|
||||
type emailVorlagenListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Zeilen []emailVorlageZeile
|
||||
ActionBase string // unterscheidet Mandant- von Betreiber-Ziel-URL im gemeinsamen Template
|
||||
}
|
||||
|
||||
func (s *Server) handleEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen", Nav: navFor(r), ActionBase: "/verwaltung/email-vorlagen"}
|
||||
for _, t := range emailVorlageTypen {
|
||||
_, err := s.store.GetEmailVorlage(r.Context(), &accountID, t.Typ)
|
||||
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label, IstUebersteuert: err == nil})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type emailVorlageBearbeitenData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Typ string
|
||||
Label string
|
||||
Platzhalter string
|
||||
Betreff string
|
||||
Text string
|
||||
IstUebersteuert bool
|
||||
ActionBase string
|
||||
Error string
|
||||
Gespeichert bool
|
||||
}
|
||||
|
||||
func (s *Server) handleEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
|
||||
typ := r.PathValue("typ")
|
||||
info, ok := findEmailVorlageTyp(typ)
|
||||
if !ok {
|
||||
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
resolved, err := s.store.ResolveEmailVorlage(r.Context(), accountID, typ)
|
||||
if err != nil {
|
||||
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, uebersteuert := s.store.GetEmailVorlage(r.Context(), &accountID, typ)
|
||||
data := emailVorlageBearbeitenData{
|
||||
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
|
||||
Betreff: resolved.Betreff, Text: resolved.Text, IstUebersteuert: uebersteuert == nil,
|
||||
ActionBase: "/verwaltung/email-vorlagen", Gespeichert: r.URL.Query().Get("gespeichert") == "1",
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||
typ := r.PathValue("typ")
|
||||
if _, ok := findEmailVorlageTyp(typ); !ok {
|
||||
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
betreff := strings.TrimSpace(r.FormValue("betreff"))
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if betreff == "" || text == "" {
|
||||
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpsertEmailVorlage(r.Context(), &accountID, typ, betreff, text); err != nil {
|
||||
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmailVorlageZuruecksetzen(w http.ResponseWriter, r *http.Request) {
|
||||
typ := r.PathValue("typ")
|
||||
if _, ok := findEmailVorlageTyp(typ); !ok {
|
||||
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
if err := s.store.DeleteEmailVorlage(r.Context(), &accountID, typ); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "Zurücksetzen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ─── Betreiber (Ebene 5): plattformweiter Standard ─────────────────────
|
||||
|
||||
func (s *Server) handleBetreiberEmailVorlagenListe(w http.ResponseWriter, r *http.Request) {
|
||||
data := emailVorlagenListeData{Title: "E-Mail-Vorlagen (Plattform-Standard)", Nav: navFor(r), ActionBase: "/betreiber/email-vorlagen"}
|
||||
for _, t := range emailVorlageTypen {
|
||||
data.Zeilen = append(data.Zeilen, emailVorlageZeile{Typ: t.Typ, Label: t.Label})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "email-vorlagen-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberEmailVorlageBearbeitenForm(w http.ResponseWriter, r *http.Request) {
|
||||
typ := r.PathValue("typ")
|
||||
info, ok := findEmailVorlageTyp(typ)
|
||||
if !ok {
|
||||
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
v, err := s.store.GetEmailVorlage(r.Context(), nil, typ)
|
||||
if err != nil {
|
||||
http.Error(w, "E-Mail-Vorlage konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := emailVorlageBearbeitenData{
|
||||
Title: info.Label, Nav: navFor(r), Typ: typ, Label: info.Label, Platzhalter: info.Platzhalter,
|
||||
Betreff: v.Betreff, Text: v.Text, ActionBase: "/betreiber/email-vorlagen",
|
||||
Gespeichert: r.URL.Query().Get("gespeichert") == "1",
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "email-vorlage-bearbeiten", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberEmailVorlageSpeichern(w http.ResponseWriter, r *http.Request) {
|
||||
typ := r.PathValue("typ")
|
||||
if _, ok := findEmailVorlageTyp(typ); !ok {
|
||||
http.Error(w, "unbekannter Vorlagen-Typ", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
betreff := strings.TrimSpace(r.FormValue("betreff"))
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if betreff == "" || text == "" {
|
||||
http.Error(w, "Betreff und Text sind Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpsertEmailVorlage(r.Context(), nil, typ, betreff, text); err != nil {
|
||||
http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/email-vorlagen/"+typ+"?gespeichert=1", http.StatusSeeOther)
|
||||
}
|
||||
113
internal/web/email_vorlage_handlers_test.go
Normal file
113
internal/web/email_vorlage_handlers_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminKannEigeneEmailVorlageSpeichernUndZuruecksetzen(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
listResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
|
||||
if listResp.Code != http.StatusOK {
|
||||
t.Fatalf("Liste-Status = %d, want 200", listResp.Code)
|
||||
}
|
||||
if !strings.Contains(listResp.Body.String(), "/verwaltung/email-vorlagen/passwort_zuruecksetzen") {
|
||||
t.Fatalf("erwartet Link zur Bearbeiten-Seite, body: %s", listResp.Body.String())
|
||||
}
|
||||
|
||||
getResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
|
||||
if getResp.Code != http.StatusOK {
|
||||
t.Fatalf("GET Bearbeiten-Seite status = %d, want 200", getResp.Code)
|
||||
}
|
||||
if !strings.Contains(getResp.Body.String(), "Plattform-Standard, nicht angepasst") {
|
||||
t.Fatalf("erwartet Hinweis auf geerbten Plattform-Standard, body: %s", getResp.Body.String())
|
||||
}
|
||||
|
||||
saveResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
|
||||
"betreff": {"Mein eigener Betreff"}, "text": {"Mein eigener Text {{link}}"},
|
||||
})
|
||||
if saveResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
|
||||
}
|
||||
|
||||
afterResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
|
||||
body := afterResp.Body.String()
|
||||
if !strings.Contains(body, "Mein eigener Betreff") {
|
||||
t.Fatalf("gespeicherter Betreff fehlt, body: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "(eigene Vorlage)") {
|
||||
t.Fatalf("erwartet Hinweis auf eigene Vorlage, body: %s", body)
|
||||
}
|
||||
|
||||
resetResp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen/zuruecksetzen", url.Values{})
|
||||
if resetResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("reset status = %d, want 303", resetResp.Code)
|
||||
}
|
||||
finalResp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen")
|
||||
if strings.Contains(finalResp.Body.String(), "Mein eigener Betreff") {
|
||||
t.Fatal("nach Zurücksetzen ist die eigene Vorlage immer noch aktiv")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberKannPlattformStandardBearbeiten(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Betreiber-Firma", "betreiber@example.com", "betreiber")
|
||||
|
||||
saveResp := postForm(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen", url.Values{
|
||||
"betreff": {"Neuer Plattform-Betreff"}, "text": {"Neuer Plattform-Text {{link}}"},
|
||||
})
|
||||
if saveResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String())
|
||||
}
|
||||
|
||||
getResp := getWithCookie(t, s, cookie, "/betreiber/email-vorlagen/passwort_zuruecksetzen")
|
||||
if !strings.Contains(getResp.Body.String(), "Neuer Plattform-Betreff") {
|
||||
t.Fatalf("neuer Plattform-Standard fehlt, body: %s", getResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNichtAdminKannEmailVorlagenNichtBearbeiten(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/verwaltung/email-vorlagen")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswortVergessenNutztMandantenspezifischeVorlage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s, fm := newServerWithMailer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Vorlage-Firma", "vorlage-admin@example.com", "admin")
|
||||
|
||||
if resp := postForm(t, s, cookie, "/verwaltung/email-vorlagen/passwort_zuruecksetzen", url.Values{
|
||||
"betreff": {"Firmenspezifischer Betreff"}, "text": {"Hallo! Link: {{link}} Ende."},
|
||||
}); resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save status = %d, want 303", resp.Code)
|
||||
}
|
||||
|
||||
if resp := postForm(t, s, nil, "/passwort-vergessen", url.Values{"email": {"vorlage-admin@example.com"}}); resp.Code != http.StatusOK {
|
||||
t.Fatalf("passwort-vergessen status = %d, want 200", resp.Code)
|
||||
}
|
||||
if len(fm.Sent) != 1 {
|
||||
t.Fatalf("got %d gesendete Mails, want 1", len(fm.Sent))
|
||||
}
|
||||
if fm.Sent[0].Subject != "Firmenspezifischer Betreff" {
|
||||
t.Fatalf("Subject = %q, want die firmenspezifische Vorlage", fm.Sent[0].Subject)
|
||||
}
|
||||
if !strings.Contains(fm.Sent[0].Body, "Hallo! Link: ") || strings.Contains(fm.Sent[0].Body, "{{link}}") {
|
||||
t.Fatalf("Body = %q, want Platzhalter ersetzt und Rest der eigenen Vorlage erhalten", fm.Sent[0].Body)
|
||||
}
|
||||
if !regexp.MustCompile(`/passwort-zuruecksetzen/[a-f0-9]+`).MatchString(fm.Sent[0].Body) {
|
||||
t.Fatalf("kein Reset-Link im Body gefunden: %q", fm.Sent[0].Body)
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,16 @@ func (s *Server) handlePasswortVergessen(w http.ResponseWriter, r *http.Request)
|
||||
scheme = "https"
|
||||
}
|
||||
link := fmt.Sprintf("%s://%s/passwort-zuruecksetzen/%s", scheme, r.Host, token)
|
||||
body := "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n" + link + "\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
|
||||
betreff, text := "Deklarix — Passwort zurücksetzen", "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail."
|
||||
if vorlage, err := s.store.ResolveEmailVorlage(r.Context(), user.AccountID, "passwort_zuruecksetzen"); err == nil {
|
||||
betreff, text = vorlage.Betreff, vorlage.Text
|
||||
}
|
||||
// Versandfehler wird bewusst nicht an den Nutzer durchgereicht
|
||||
// (keine Enumeration über unterschiedliches Verhalten bei
|
||||
// Versandfehlern), aber serverseitig geloggt statt
|
||||
// verschluckt — sonst bleibt ein dauerhaft falsch
|
||||
// konfigurierter SMTP-Server unbemerkt.
|
||||
if err := s.mailer.Send(user.Email, "Deklarix — Passwort zurücksetzen", body); err != nil {
|
||||
if err := s.mailer.Send(user.Email, betreff, renderEmailVorlage(text, link)); err != nil {
|
||||
log.Printf("passwort-vergessen: mail versand fehlgeschlagen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ type Store interface {
|
||||
SetUserPassword(ctx context.Context, id, passwordHash string) error
|
||||
UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (store.LoeschfristEinstellung, error)
|
||||
ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]store.LoeschfristEinstellung, error)
|
||||
UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error)
|
||||
GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error)
|
||||
ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error)
|
||||
DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error
|
||||
CreatePasswordResetToken(ctx context.Context, userID, token string) (store.PasswordResetToken, error)
|
||||
GetValidPasswordResetToken(ctx context.Context, token string) (store.PasswordResetToken, error)
|
||||
MarkPasswordResetTokenUsed(ctx context.Context, id string) error
|
||||
@@ -162,6 +166,13 @@ func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, erro
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe))
|
||||
mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern))
|
||||
mux.HandleFunc("GET /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe))
|
||||
mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm))
|
||||
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageSpeichern))
|
||||
mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}/zuruecksetzen", s.requireAdmin(s.handleEmailVorlageZuruecksetzen))
|
||||
mux.HandleFunc("GET /betreiber/email-vorlagen", s.requireBetreiber(s.handleBetreiberEmailVorlagenListe))
|
||||
mux.HandleFunc("GET /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageBearbeitenForm))
|
||||
mux.HandleFunc("POST /betreiber/email-vorlagen/{typ}", s.requireBetreiber(s.handleBetreiberEmailVorlageSpeichern))
|
||||
mux.HandleFunc("GET /passwort-vergessen", s.handlePasswortVergessenForm)
|
||||
mux.HandleFunc("POST /passwort-vergessen", s.handlePasswortVergessen)
|
||||
mux.HandleFunc("GET /passwort-zuruecksetzen/{token}", s.handlePasswortZuruecksetzenForm)
|
||||
|
||||
@@ -45,7 +45,18 @@ type fakeStore struct {
|
||||
freigabeRegeln map[string]store.FreigabeRegel
|
||||
freigabeschritte map[string]store.Freigabeschritt
|
||||
passwordResetTokens map[string]store.PasswordResetToken
|
||||
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||
loeschfristen map[string]map[string]int // accountID -> datenklasseID -> maxTage
|
||||
emailVorlagen map[string]store.EmailVorlage // key: accountKeyFor(accountID)+"|"+typ
|
||||
}
|
||||
|
||||
// accountKeyFor macht nil und "" für die Plattform-Vorlage im
|
||||
// fakeStore-Key ununterscheidbar von einer echten, aber leeren
|
||||
// account_id — genügt für Tests, da echte account_ids nie leer sind.
|
||||
func accountKeyFor(accountID *string) string {
|
||||
if accountID == nil {
|
||||
return ""
|
||||
}
|
||||
return *accountID
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
@@ -67,6 +78,16 @@ func newFakeStore() *fakeStore {
|
||||
freigabeschritte: map[string]store.Freigabeschritt{},
|
||||
passwordResetTokens: map[string]store.PasswordResetToken{},
|
||||
loeschfristen: map[string]map[string]int{},
|
||||
emailVorlagen: map[string]store.EmailVorlage{
|
||||
// Platzhalter für den plattformweiten Standard, wie ihn
|
||||
// Migration 0022 für echtes Postgres seedet — sonst würde
|
||||
// ResolveEmailVorlage im fakeStore ErrNotFound liefern,
|
||||
// wo die echte DB immer einen Treffer hat.
|
||||
"|passwort_zuruecksetzen": {
|
||||
Typ: "passwort_zuruecksetzen", Betreff: "Deklarix — Passwort zurücksetzen",
|
||||
Text: "Hallo,\n\nüber diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):\n{{link}}\n\nFalls du das nicht angefordert hast, ignoriere diese E-Mail.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +102,48 @@ func (f *fakeStore) SetTenantScope(ctx context.Context, accountID string, isBetr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (store.EmailVorlage, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
key := accountKeyFor(accountID) + "|" + typ
|
||||
v := store.EmailVorlage{ID: f.newID(), AccountID: accountID, Typ: typ, Betreff: betreff, Text: text, UpdatedAt: time.Now()}
|
||||
f.emailVorlagen[key] = v
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (store.EmailVorlage, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
v, ok := f.emailVorlagen[accountKeyFor(accountID)+"|"+typ]
|
||||
if !ok {
|
||||
return store.EmailVorlage{}, store.ErrNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (store.EmailVorlage, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if v, ok := f.emailVorlagen[accountID+"|"+typ]; ok {
|
||||
return v, nil
|
||||
}
|
||||
if v, ok := f.emailVorlagen["|"+typ]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return store.EmailVorlage{}, store.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
key := accountKeyFor(accountID) + "|" + typ
|
||||
if _, ok := f.emailVorlagen[key]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(f.emailVorlagen, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) newID() string {
|
||||
f.nextID++
|
||||
return fmt.Sprintf("id-%d", f.nextID)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<span class="stat-card-label">Audit-Log (neueste)</span>
|
||||
</a>
|
||||
</li>
|
||||
<li><a class="stat-card stat-card-action" href="/betreiber/email-vorlagen"><span class="stat-card-label">E-Mail-Vorlagen →</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
49
internal/web/templates/email_vorlage_bearbeiten.html
Normal file
49
internal/web/templates/email_vorlage_bearbeiten.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{{define "email-vorlage-bearbeiten"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page page-wide">
|
||||
<p><a href="{{.ActionBase}}">← E-Mail-Vorlagen</a></p>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{.Label}}</h1>
|
||||
<p class="hinweis">
|
||||
Platzhalter in geschweiften Klammern werden beim Versand ersetzt.
|
||||
Verfügbar: {{.Platzhalter}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
|
||||
<div class="form-card">
|
||||
<form method="post" action="{{.ActionBase}}/{{.Typ}}">
|
||||
<fieldset class="form-section">
|
||||
<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>
|
||||
<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>
|
||||
</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/{{.Typ}}/zuruecksetzen" onsubmit="return confirm('Eigene Vorlage löschen und zum Plattform-Standard zurückkehren?')">
|
||||
<button type="submit" class="entfernen">Auf Plattform-Standard zurücksetzen</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
30
internal/web/templates/email_vorlagen_liste.html
Normal file
30
internal/web/templates/email_vorlagen_liste.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{define "email-vorlagen-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page page-wide">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="hinweis">Betreff und Text der vom System versendeten E-Mails.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Zeilen}}
|
||||
<p class="hinweis">Es gibt noch keine vom System versendeten E-Mails.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Zeilen}}
|
||||
<li>
|
||||
<a href="{{$.ActionBase}}/{{.Typ}}">
|
||||
<strong>{{.Label}}</strong>
|
||||
{{if .IstUebersteuert}}<span class="status status-published">eigene Vorlage</span>{{else}}<span class="status status-mittel">Plattform-Standard</span>{{end}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -35,6 +35,7 @@
|
||||
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/loeschfristen">Löschfristen</a>{{end}}
|
||||
{{if .IsAdmin}}<a href="/verwaltung/email-vorlagen">E-Mail-Vorlagen</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
Reference in New Issue
Block a user