Compare commits

...
6 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 11910dd6dd refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:23:05 +02:00
Nick SzaunigandClaude Opus 5.5 0620e9d79a feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When
SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET
are set, the shop fetches an app-only token from Entra ID and signs in to
smtp.office365.com with XOAUTH2. This works with security defaults and
MFA, survives the end of SMTP basic auth (default off from end of
December 2026) and limits the app to the one mailbox Exchange grants it
(SMTP.SendAsApp + Add-MailboxPermission). Password login stays available
for other providers; a partial OAuth setup never silently falls back.

- lib/ms365-oauth.ts: token cached until 5 minutes before expiry,
  concurrent requests share one fetch, forced refresh after a server
  rejection, 15 s timeout, tenant id validated before it enters the URL,
  client secret redacted from every error.
- lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of
  value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant)
  and maps a rejected XOAUTH2 login to missing mailbox access.
- Admin e-mail test shows the auth mode and lists missing settings.
- k8s manifests, preflight check and README (step-by-step Entra and
  Exchange setup) updated; misplaced SMTP_PORT comment moved.

Verified against Entra ID and smtp.office365.com with fake values:
AADSTS90002 is classified as tenant error without leaking the secret,
an invalid token yields 535 5.7.3 and is classified as mailbox access.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:17:33 +02:00
Nick SzaunigandClaude Opus 5.5 302a18baa6 feat(mail): SMTP diagnosis and admin email test for Microsoft 365
Mail failures were only logged as a bare error, so a broken Microsoft
365 setup showed up as "no emails arrive" with no cause. Now:

- lib/smtp-diagnose.ts maps SMTP errors to a cause with German
  instructions: security defaults, SMTP AUTH disabled for tenant or
  mailbox, MFA/conditional access, SendAsDenied (SMTP_FROM differs from
  the mailbox), wrong credentials, basic-auth retirement, network, TLS.
  The password is redacted from any echoed server message.
- /admin/e-mail-test verifies login and sends a real test email to the
  signed-in admin only (no free recipient, rate limited, audit logged)
  and shows the server message, the cause and the settings in use.
- The transport now requires STARTTLS on port 587 so credentials are
  never sent unencrypted, strips a trailing newline from secrets (a
  common kubectl/base64 mistake) and logs the diagnosed cause.

Verified against smtp.office365.com with a fake account: STARTTLS
succeeds and "535 5.7.3" is classified as wrong credentials.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:48:40 +02:00
Nick SzaunigandClaude Opus 5.5 cef2248a45 fix(security): escape untrusted values in transactional email HTML
Customer input (first name, address, product names), order data, admin
fields (company data, carrier, tracking number) and URLs were inserted
into email HTML unescaped. A first name such as <a href="…">…</a> ended
up as a working link in a genuine shop email, usable for phishing.

All variable values in email HTML now go through escapeHtml
(lib/escape-html.ts, shared with the withdrawal receipt). Button links
only accept http(s), so a javascript: tracking URL is dropped. The plain
text part stays unescaped. Tests cover order confirmation, shipment,
status mails, layout company data and back-in-stock mails.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:42:49 +02:00
Nick SzaunigandClaude Opus 5.5 dcfad4e011 feat(legal): electronic withdrawal function and remaining compliance fixes
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 11:14:02 +02:00
Nick SzaunigandClaude Opus 5.5 4ab1a7edae feat(legal): readable legal pages and corrected legal texts
Legal pages rendered admin text as bare paragraphs. LegalPage now has a
honeycomb header, a sticky table of contents with scroll tracking (a
collapsible one on mobile), anchor links, a reading progress bar and a
revision date pulled from a "Stand:" line. lib/legal-blocks.ts derives
lists, cookie/provider entries with labelled facts and safe links from
plain text, so the pages stay editable in the admin without markup.

Legal corrections (German law as of 2026):
- Widerrufsbelehrung now follows the official model (Anlage 1 und 2 zu
  Art. 246a EGBGB): recipient name/address/e-mail filled from company
  data, deadline rule, return period, refund details, diminished value
  and the model withdrawal form. The incomplete notice extended the
  withdrawal period to up to 12 months and 14 days. Honey is no longer
  declared perishable.
- Impressum: TMG references replaced by DDG; editorial contact gains an
  address.
- Datenschutz: removes the self-hosted vs. hosting-provider
  contradiction, adds PayPal/Klarna, reviews, back-in-stock
  notifications, storage period, no automated decisions, withdrawal of
  consent and the Art. 21 objection notice; newsletter double opt-in.
- AGB: payment methods and delivery area point to the live shipping
  page; adds contract-text and input-correction information.
- Cookie policy: storage period and provider for every entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 07:02:12 +02:00
49 changed files with 2595 additions and 198 deletions

No files matched your search

+14 -9
View File
@@ -79,16 +79,16 @@ GOOGLE_PLACE_ID=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
NEXT_PUBLIC_TURNSTILE_SITE_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# SMTP fuer Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in, # Transaktions-E-Mails (Bestellbestaetigung, Newsletter-Opt-in,
# Passwort-Reset). Ohne diese drei Werte werden E-Mails nur geloggt statt # Passwort-Reset) gehen ueber Microsoft 365 mit OAuth 2.0 — mit derselben
# versendet — praktisch fuer lokale Entwicklung ohne Mail-Setup. # App-Registrierung wie "Mit Microsoft anmelden" (ENTRA_* weiter unten, dort
# Funktioniert mit jedem SMTP-Anbieter (z.B. das E-Mail-Postfach eures # zusaetzlich die Berechtigung SMTP.SendAsApp, siehe README
# Domain-Hosters wie IONOS/Strato, oder ein Dienst wie Resend/Postmark im # "E-Mail-Versand"). Ein Passwort gibt es nicht. Ohne SMTP_USER und die
# SMTP-Modus). # ENTRA_*-Werte werden E-Mails nur geloggt statt versendet — praktisch fuer
SMTP_HOST= # lokale Entwicklung ohne Mail-Setup.
SMTP_PORT=587 # SMTP_USER = das Postfach, mit dem gesendet wird; SMTP_FROM sollte dieselbe
# Adresse tragen.
SMTP_USER= SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="Wabenhain <[email protected]>" SMTP_FROM="Wabenhain <[email protected]>"
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten # ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
@@ -187,6 +187,11 @@ DHL_ENVIRONMENT=sandbox
# als Unternehmensapplikation (wird beim App-Registration-Schritt # als Unternehmensapplikation (wird beim App-Registration-Schritt
# automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis # automatisch mit angelegt) — dort laesst sich optional der Nutzerkreis
# (z.B. nur bestimmte Gruppen) einschraenken. # (z.B. nur bestimmte Gruppen) einschraenken.
# 6. Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben): dafuer
# die Anwendungsberechtigung "Office 365 Exchange Online -> SMTP.SendAsApp"
# mit Administratorzustimmung ergaenzen und in Exchange das Postfach
# freigeben (README "E-Mail-Versand"). Laeuft das Secret ab, sind
# Anmeldung UND Mailversand betroffen.
ENTRA_TENANT_ID= ENTRA_TENANT_ID=
ENTRA_CLIENT_ID= ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET= ENTRA_CLIENT_SECRET=
+24 -5
View File
@@ -40,7 +40,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe.
- **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In - **Kundenkonto** unter `/konto`: Registrierung mit **Pflicht-E-Mail-Verifizierung** (siehe „Sicherheit" unten) und Login (Passwort-Hash + signiertes Session-Cookie), optionale **Zwei-Faktor-Authentifizierung** (TOTP-Authenticator-App + Backup-Codes, `/konto/zwei-faktor`), Konto-Sperre nach Fehlversuchen, "Alle Geräte abmelden", "Passwort vergessen"-Link mit E-Mail-Reset, Bestellhistorie mit Versand-/Abholstatus, "Bestellung erneut auslösen", Adressverwaltung (mehrere Adressen, Standardadresse), **Wunschliste**, Rechnungsansicht als HTML und PDF-Download (`/rechnung/[nummer]`), Newsletter-Verwaltung mit Double-Opt-In
- **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten) - **Admin-Backend** unter `/admin` (Zugriff über das normale Kundenkonto-Login + Rolle `ADMIN`, siehe „Admin-Zugang" unten): Dashboard mit Kennzahlen, Top-Produkten, Umsatz nach Kategorie, **Umsatzverlauf der letzten 14 Tage**, **Stammkund:innen-Zähler** und **meistgenutzten Gutscheinen**, Bestellliste mit Detailseite (`/admin/bestellungen/[id]`: vollständige Adresse, Artikel, Zahlung, Rabatt, Rechnungs-PDF) inkl. Status-Änderung (setzt automatisch Versand-/Abholdatum **und verschickt eine Versand-/Abholbereit-E-Mail**), Produkte/Ernten/Chargen jeweils mit Anlegen- und Bearbeiten-Formularen, Firmenanfragen-Inbox mit Statuspflege, **Benutzerverwaltung** (`/admin/benutzer`, siehe „Admin-Zugang" unten), **Versandkosten-Verwaltung** (`/admin/versand`, siehe „Versandkosten" unten), **Gutschein-Verwaltung** (`/admin/gutscheine`), **Bewertungsmoderation** (`/admin/bewertungen`: freischalten oder löschen), **Audit-Log** (`/admin/audit-log`, siehe „Sicherheit" unten)
- **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten) - **ApiCore-Synchronisierung**: Ernten, Chargen und der Produktkatalog lassen sich per Knopfdruck aus dem Imkerei-Verwaltungssystem ApiCore übernehmen, statt dieselben Daten zweimal einzutippen — jeweils in einen Entwurfszustand, den erst ein Mensch freigibt, und ohne je etwas zu überschreiben, was im Admin bewusst gesetzt wurde (siehe „ApiCore-Synchronisierung" unten)
- **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über SMTP, siehe Abschnitt „E-Mail-Versand" unten - **Transaktions-E-Mails** (Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in, Passwort-Reset) über Microsoft 365 mit OAuth 2.0 (dieselbe App wie die Microsoft-Anmeldung), siehe Abschnitt „E-Mail-Versand" unten
- `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo - `sitemap.xml`, `robots.txt`, OpenGraph-Metadaten, Favicon (`app/icon.svg`) mit dem echten Wabenhain-Logo
- **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite. - **Barrierefreiheit (WCAG AA)**: die Honigfarbe gibt es jetzt als zwei kontraststarke Varianten (`text-honey-dark` für helle, `text-honey-light` für dunkle Abschnitte wie `bg-forest`) statt eines einzelnen Tons, der auf keinem der beiden Hintergründe zuverlässig 4.5:1 erreichte; dieselbe Kontrastprüfung wurde site-weit für alle `text-ink/NN`-Texttöne nachgezogen (Admin-Bereich, Kundenkonto, Checkout, Rechnungen, Produktkarten, Geschmacksskala — 190+ Stellen in 50 Dateien) sowie für die Bildergalerie auf der Produktseite korrigiert (Vorschaubild-Buttons hatten einen `aria-label`, der den sichtbaren Bildtext überschrieben statt eingeschlossen hat). Lighthouse-Accessibility-Audit steht jetzt bei 100/100 ohne offene Verstöße auf Start-, Shop- und Produktseite.
- **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45&nbsp;KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten. - **Performance**: GSAP (inkl. ScrollTrigger-Plugin) wurde als Animationsbibliothek entfernt und durch `motion/react` ersetzt, das ohnehin schon für andere UI-Animationen geladen wird — spart ein komplettes zweites Animations-Framework (~45&nbsp;KB gzip) ohne sichtbaren Unterschied im Scroll-Reveal- oder Hero-Effekt. Core-Web-Vitals-Messung (LCP/CLS) zeigt keine Auffälligkeiten.
@@ -258,9 +258,28 @@ Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die K
## E-Mail-Versand ## E-Mail-Versand
Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset werden über SMTP versendet (`src/lib/mail.ts`, `nodemailer`). Ohne `SMTP_HOST`/`SMTP_USER`/`SMTP_PASSWORD` in `.env` wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt konfigurieren. Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset gehen über **Microsoft 365 mit OAuth 2.0** (`src/lib/mail.ts`, `nodemailer`, SMTP mit XOAUTH2 an `smtp.office365.com:587`). Als App dient **dieselbe App-Registrierung wie „Mit Microsoft anmelden“** (`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`) — eine Unternehmensanwendung statt zwei. Eine Anmeldung per Passwort (Basic Auth) gibt es nicht: kein Benutzerpasswort im Cluster, kein Konflikt mit MFA oder Sicherheitsstandards, nicht betroffen vom Ende der Basic Auth bei Exchange Online. Ohne `SMTP_USER` und die `ENTRA_*`-Werte wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt einrichten.
Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Hosters (IONOS, Strato, …) oder Diensten wie Resend/Postmark im SMTP-Modus. `SMTP_FROM` sollte zur `SMTP_USER`-Domain passen, sonst landen Mails leicht im Spam. Ob der Versand funktioniert, zeigt **Admin → E-Mail-Test**: Token, Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung von Microsoft samt Ursache.
**Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen):
1. **Absender-Postfach:** Am besten ein freigegebenes Postfach (z. B. `[email protected]`) — braucht keine Lizenz und hat kein Passwort, mit dem sich jemand anmelden könnte. `SMTP_USER` und die Adresse in `SMTP_FROM` sind dieses Postfach.
2. **Berechtigung an der SSO-App:** Entra Admin Center → App-Registrierungen → die SSO-App → API-Berechtigungen → Berechtigung hinzufügen → „Von meiner Organisation verwendete APIs“ → **Office 365 Exchange Online** → Anwendungsberechtigungen → **SMTP.SendAsApp** → danach „Administratorzustimmung erteilen“.
3. **Objekt-ID der Unternehmensanwendung:** Entra → Unternehmensanwendungen → die SSO-App → **Objekt-ID** (nicht die der App-Registrierung).
4. **Exchange-Zugriff auf genau dieses Postfach** (Exchange Online PowerShell):
```powershell
Install-Module ExchangeOnlineManagement # einmalig
Connect-ExchangeOnline
New-ServicePrincipal -AppId <ENTRA_CLIENT_ID> -ObjectId <Objekt-ID aus Schritt 3> -DisplayName "Wabenhain Shop"
Add-MailboxPermission -Identity "[email protected]" -User <Objekt-ID aus Schritt 3> -AccessRights FullAccess
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
```
Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten.
5. **Kubernetes:** `SMTP_USER` im Secret setzen; `SMTP_HOST`, `SMTP_PORT` und `SMTP_PASSWORD` aus bestehenden Secrets/ConfigMaps entfernen. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → E-Mail-Test**.
6. **Aufräumen:** Das bisher für SMTP genutzte Benutzerpasswort ändern; „Authentifiziertes SMTP“ bleibt nur für das Absender-Postfach an.
**Sicherheit:** Das SSO-Client-Secret darf damit auch senden — Exchange beschränkt das aber auf das eine freigegebene Postfach. Läuft das Secret ab, fallen **Anmeldung und Mailversand gleichzeitig** aus; der E-Mail-Test meldet dann „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, `ENTRA_CLIENT_SECRET` ersetzen, Pod neu starten. Ablaufdatum im Kalender eintragen.
## CAPTCHA (Cloudflare Turnstile) ## CAPTCHA (Cloudflare Turnstile)
@@ -338,7 +357,7 @@ Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://loc
pnpm preflight pnpm preflight
``` ```
**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, kein SMTP und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist. **Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, keinen Mailversand und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist.
Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen: Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen:
@@ -353,7 +372,7 @@ Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauc
- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar). - `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar).
- `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags. - `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags.
- Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail. - Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail.
- SMTP vollständig gesetzt — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung. - Mailversand eingerichtet (`SMTP_USER` + `ENTRA_*`) — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung.
- Rechtstexte (Impressum, Datenschutz, AGB, Widerrufsbelehrung) und Telefonnummer auf verbliebene `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen — je Sprachfassung einzeln benannt. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig. - Rechtstexte (Impressum, Datenschutz, AGB, Widerrufsbelehrung) und Telefonnummer auf verbliebene `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen — je Sprachfassung einzeln benannt. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig.
Diese Prüfung fragt die **Datenbank**, nicht den Quelltext: all diese Texte sind im Admin-Bereich unter „Inhalte" pflegbar (siehe `lib/content-registry.ts`), die Fassungen in `src/i18n/dictionaries/` sind nur der Rückfallwert, solange dort nichts gepflegt ist. Zum Ausfüllen des Impressums ist also **kein Deploy nötig** — und eine Prüfung, die nur in den Quelltext schaut, würde einen im Admin längst geschlossenen Blocker weiter melden (und umgekehrt schweigen, wenn jemand einen Platzhaltertext erst im Admin einträgt). Diese Prüfung fragt die **Datenbank**, nicht den Quelltext: all diese Texte sind im Admin-Bereich unter „Inhalte" pflegbar (siehe `lib/content-registry.ts`), die Fassungen in `src/i18n/dictionaries/` sind nur der Rückfallwert, solange dort nichts gepflegt ist. Zum Ausfüllen des Impressums ist also **kein Deploy nötig** — und eine Prüfung, die nur in den Quelltext schaut, würde einen im Admin längst geschlossenen Blocker weiter melden (und umgekehrt schweigen, wenn jemand einen Platzhaltertext erst im Admin einträgt).
+6 -3
View File
@@ -120,11 +120,14 @@ services:
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY} GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY}
GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID} GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID}
SMTP_HOST: ${SMTP_HOST} # Mailversand ueber Microsoft 365 mit OAuth 2.0 — dieselbe
SMTP_PORT: ${SMTP_PORT:-587} # App-Registrierung wie "Mit Microsoft anmelden" (siehe .env.example).
SMTP_USER: ${SMTP_USER} SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>} SMTP_FROM: ${SMTP_FROM:-Wabenhain <[email protected]>}
ENTRA_TENANT_ID: ${ENTRA_TENANT_ID}
ENTRA_CLIENT_ID: ${ENTRA_CLIENT_ID}
ENTRA_CLIENT_SECRET: ${ENTRA_CLIENT_SECRET}
ENTRA_REDIRECT_URI: ${ENTRA_REDIRECT_URI}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
NODE_ENV: production NODE_ENV: production
+2 -4
View File
@@ -28,6 +28,8 @@ data:
# Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen # Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner. # Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
# Muss die Adresse des Postfachs aus SMTP_USER (Secret) sein — der Versand
# laeuft ueber Microsoft 365 mit OAuth 2.0, siehe README "E-Mail-Versand".
SMTP_FROM: "Wabenhain <[email protected]>" SMTP_FROM: "Wabenhain <[email protected]>"
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
@@ -42,8 +44,6 @@ data:
# Prisma-Client-Varianten, siehe src/lib/prisma.ts. # Prisma-Client-Varianten, siehe src/lib/prisma.ts.
DATABASE_PROVIDER: "postgresql" DATABASE_PROVIDER: "postgresql"
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
# implizites TLS.
# Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20. # Gleichzeitig offene Datenbankverbindungen JE INSTANZ. Leer = 20.
# #
# Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas" # Beim Hochskalieren mitdenken: der Wert multipliziert sich mit "replicas"
@@ -53,8 +53,6 @@ data:
# welche. Entweder diesen Wert senken oder max_connections anheben. # welche. Entweder diesen Wert senken oder max_connections anheben.
DATABASE_POOL_SIZE: "20" DATABASE_POOL_SIZE: "20"
SMTP_PORT: "587"
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
# des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App # des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
# weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad. # weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
+12 -6
View File
@@ -68,13 +68,15 @@ stringData:
# https://wabenhain.com/api/checkout/webhook zeigt (whsec_...). # https://wabenhain.com/api/checkout/webhook zeigt (whsec_...).
STRIPE_WEBHOOK_SECRET: "CHANGE-ME" STRIPE_WEBHOOK_SECRET: "CHANGE-ME"
# Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (src/lib/mail.ts): # Mailversand ueber Microsoft 365 mit OAuth 2.0 (src/lib/mail.ts) — mit der
# keine Bestellbestaetigung, keine Versandmeldung, keine # App-Registrierung der Entra-ID-SSO unten (ENTRA_TENANT_ID,
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und # ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), kein Passwort. Ohne diese Werte
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml. # und SMTP_USER werden E-Mails nur ins Server-Log geschrieben: keine
SMTP_HOST: "CHANGE-ME" # Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung.
# SMTP_USER ist das Postfach, mit dem gesendet wird (z. B.
# [email protected]) — dieselbe Adresse wie SMTP_FROM in
# 01-configmap.yaml. Einrichtung: README "E-Mail-Versand".
SMTP_USER: "CHANGE-ME" SMTP_USER: "CHANGE-ME"
SMTP_PASSWORD: "CHANGE-ME"
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt # EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt
@@ -127,6 +129,10 @@ stringData:
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die # Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst. # Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
# Dieselbe App versendet auch die Shop-E-Mails (SMTP_USER oben, Berechtigung
# SMTP.SendAsApp) — ohne diese Werte also auch kein Mailversand. Das
# Client-Secret laeuft ab (hoechstens 24 Monate): Ablaufdatum im Kalender
# eintragen, sonst fallen Anmeldung UND Mails gleichzeitig aus.
# ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse # ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse
# entsprechen und auf die echte Domain zeigen: # entsprechen und auf die echte Domain zeigen:
# https://wabenhain.com/api/auth/entra/callback # https://wabenhain.com/api/auth/entra/callback
@@ -0,0 +1,27 @@
-- Elektronische Widerrufsfunktion (§ 356a BGB, Pflicht seit 19.06.2026)
-- und Nachweis der Newsletter-Einwilligung (Art. 7 Abs. 1 DSGVO).
-- CreateTable
CREATE TABLE "withdrawal_declarations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"email" TEXT NOT NULL,
"message" TEXT,
"locale" TEXT NOT NULL DEFAULT 'de',
"orderId" TEXT,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"confirmationSentAt" TIMESTAMP(3),
CONSTRAINT "withdrawal_declarations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "withdrawal_declarations_receivedAt_idx" ON "withdrawal_declarations"("receivedAt");
-- AddForeignKey
ALTER TABLE "withdrawal_declarations" ADD CONSTRAINT "withdrawal_declarations_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
-- Nullable: fuer bestehende Abonnements ist der Zeitpunkt nicht bekannt.
ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmedAt" TIMESTAMP(3);
@@ -0,0 +1,9 @@
-- Bearbeitungsstand der Widerrufserklaerungen: null = offen. Die Zahl offener
-- Widerrufe steht im Admin-Menue, damit die 14-Tage-Erstattungsfrist
-- (§ 357 Abs. 1 BGB) nicht uebersehen wird.
-- AlterTable
ALTER TABLE "withdrawal_declarations" ADD COLUMN "processedAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "withdrawal_declarations_processedAt_idx" ON "withdrawal_declarations"("processedAt");
+32
View File
@@ -439,6 +439,7 @@ model Order {
pickup Pickup? pickup Pickup?
invoice Invoice? invoice Invoice?
returnRequest ReturnRequest? returnRequest ReturnRequest?
withdrawalDeclarations WithdrawalDeclaration[]
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach /// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
/// createdAt) als auch den reinen customerId-Filter beim Datenexport. /// createdAt) als auch den reinen customerId-Filter beim Datenexport.
@@ -649,6 +650,9 @@ model NewsletterSubscriber {
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto. /// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
confirmationToken String? @unique confirmationToken String? @unique
subscribedAt DateTime @default(now()) subscribedAt DateTime @default(now())
/// Zeitpunkt des Klicks auf den Double-Opt-in-Link — Nachweis der
/// Einwilligung (Art. 7 Abs. 1 DSGVO). Null bei Altbestand vor Einfuehrung.
confirmedAt DateTime?
unsubscribedAt DateTime? unsubscribedAt DateTime?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@ -805,3 +809,31 @@ model BusinessInquiry {
@@map("business_inquiries") @@map("business_inquiries")
} }
/// Widerrufserklaerung ueber die elektronische Widerrufsfunktion
/// (§ 356a BGB, Seite /vertrag-widerrufen). Wird IMMER gespeichert, auch wenn
/// Bestellnummer und E-Mail zu keiner Bestellung passen: der Widerruf ist mit
/// dem Zugang erklaert, die Zuordnung ist unsere Sache. orderId ist nur die
/// automatische Zuordnung, wenn beides passt.
model WithdrawalDeclaration {
id String @id @default(cuid())
name String
/// Wortlaut wie eingegeben, nicht normalisiert — Beleg dessen, was erklaert wurde.
orderNumber String
email String
message String?
locale String @default("de")
orderId String?
/// Zugangszeitpunkt, steht so in der Eingangsbestaetigung (§ 356a Abs. 4 BGB).
receivedAt DateTime @default(now())
/// Gesetzt, sobald der Mailserver die Eingangsbestaetigung angenommen hat.
confirmationSentAt DateTime?
/// Im Admin als erledigt markiert (Ware zurueck, Erstattung veranlasst).
processedAt DateTime?
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([receivedAt])
@@index([processedAt])
@@map("withdrawal_declarations")
}
+4 -2
View File
@@ -146,9 +146,11 @@ mustWhenSet(
); );
// --- E-Mail ---------------------------------------------------------------- // --- E-Mail ----------------------------------------------------------------
// Versand nur ueber Microsoft 365 mit OAuth 2.0 — mit der SSO-App-
// Registrierung (siehe src/lib/mail.ts).
must( must(
"SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt", "Mailversand eingerichtet (SMTP_USER + ENTRA_TENANT_ID/ENTRA_CLIENT_ID/ENTRA_CLIENT_SECRET)",
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"), isSet("SMTP_USER") && isSet("ENTRA_TENANT_ID") && isSet("ENTRA_CLIENT_ID") && isSet("ENTRA_CLIENT_SECRET"),
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung." "Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
); );
// Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur // Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
+13 -2
View File
@@ -6,7 +6,10 @@ import { logAdminAction } from "@/lib/audit-log";
import { CONTENT_REGISTRY } from "@/lib/content-registry"; import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads"; import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared"; import { requireAdmin } from "./_shared";
import { succeed } from "@/lib/flash"; import { fail, succeed } from "@/lib/flash";
import { z } from "zod";
const COMPANY_EMAIL_KEY = "company.email";
/** /**
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte) * Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
@@ -48,7 +51,15 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) { if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) {
continue; continue;
} }
updates.push({ key: def.key, valueDe, valueEn: null }); // Die Firmen-E-Mail steht im Impressum (§ 5 Abs. 1 Nr. 2 DDG verlangt
// eine direkt nutzbare Adresse), in der Widerrufsbelehrung und in
// mailto-Links. Eine verschleierte Schreibweise ("info [at] …") waere
// dort rechtlich angreifbar und technisch unbrauchbar — daher wird der
// ganze Speichervorgang abgelehnt statt still uebernommen.
if (def.key === COMPANY_EMAIL_KEY && !z.string().trim().email().safeParse(valueDe).success) {
return fail("/admin/inhalte", "Bitte gib als Firmen-E-Mail eine vollständige, gültige Adresse an (z. B. [email protected]). Sie steht im Impressum und muss direkt nutzbar sein.");
}
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null });
} }
} }
+30
View File
@@ -0,0 +1,30 @@
"use server";
import { requireAdmin } from "@/app/admin/actions";
import { testSmtpConnection, type SmtpTestResult } from "@/lib/mail";
import { logAdminAction } from "@/lib/audit-log";
import { rateLimit } from "@/lib/rate-limit";
const TESTS_PER_WINDOW = 5;
const WINDOW_MS = 10 * 60 * 1000;
export type SmtpTestState = (SmtpTestResult & { to: string }) | { rateLimited: true } | null;
/**
* Test-Mail immer an die Adresse des angemeldeten Admins — nie an eine frei
* eingegebene, sonst waere die Aktion ein Werkzeug fuer Versand an Dritte.
* Das Limit schuetzt das Postfach vor Sperrungen durch Microsoft bei
* wiederholten Fehlanmeldungen.
*/
export async function runSmtpTestAction(): Promise<SmtpTestState> {
const admin = await requireAdmin();
if (!rateLimit(`smtp-test:${admin.id}`, TESTS_PER_WINDOW, WINDOW_MS)) return { rateLimited: true };
const result = await testSmtpConnection(admin.email);
await logAdminAction(admin, {
action: "mail.smtp_test",
entityType: "Mail",
detail: result.ok ? "erfolgreich" : result.configured ? `fehlgeschlagen: ${result.diagnosis.cause}` : "nicht eingerichtet",
});
return { ...result, to: admin.email };
}
+17
View File
@@ -0,0 +1,17 @@
import { BackLink } from "@/components/back-link";
import { AdminPageHeader } from "@/components/admin-list";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { SmtpTestPanel } from "./smtp-test-panel";
export default async function AdminEmailTestPage() {
const t = getDictionary(await getLocale());
return (
<div>
<BackLink href="/admin" label={t.adminWithdrawalsPage.backToDashboard} />
<AdminPageHeader title={t.adminEmailTestPage.title} />
<p className="mb-8 max-w-2xl text-sm text-ink/70">{t.adminEmailTestPage.intro}</p>
<SmtpTestPanel />
</div>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider";
import { runSmtpTestAction, type SmtpTestState } from "./actions";
export function SmtpTestPanel() {
const { t } = useI18n();
const s = t.adminEmailTestPage;
const [state, formAction, isPending] = useActionState<SmtpTestState>(runSmtpTestAction, null);
return (
<div className="max-w-2xl">
<form action={formAction}>
<Button type="submit" disabled={isPending}>
{isPending ? s.running : s.button}
</Button>
</form>
<div aria-live="polite" className="mt-8">
{state && "rateLimited" in state ? (
<p className="text-sm text-red-700 dark:text-red-300">Zu viele Tests. Bitte warte ein paar Minuten.</p>
) : null}
{state && "ok" in state && state.ok ? (
<p className="border border-heading/30 bg-forest/5 px-5 py-4 text-sm text-heading">{s.success(state.to)}</p>
) : null}
{state && "ok" in state && !state.ok && !state.configured ? (
<div className="space-y-2 border border-red-300 px-5 py-4 text-sm text-red-700 dark:text-red-300">
<p>{s.notConfigured}</p>
<p className="font-mono text-xs">
{s.missingLabel}: {state.missing.join(", ")}
</p>
</div>
) : null}
{state && "ok" in state && !state.ok && state.configured ? (
<div className="space-y-5 border border-red-300 px-5 py-5 text-sm">
<p className="font-serif text-xl text-red-700 dark:text-red-300">{s.failureHeading}</p>
<div>
<p className="text-xs tracking-wide-caps text-ink/60">{s.causeLabel}</p>
<p className="mt-1 leading-relaxed text-ink/85">{state.diagnosis.hint}</p>
</div>
<div>
<p className="text-xs tracking-wide-caps text-ink/60">{s.detailLabel}</p>
<p className="mt-1 break-words font-mono text-xs text-ink/80">{state.diagnosis.detail || "—"}</p>
</div>
<div>
<p className="text-xs tracking-wide-caps text-ink/60">{s.configLabel}</p>
<p className="mt-1 break-words font-mono text-xs text-ink/80">
{s.authModeOAuth} · Server {state.host} · SMTP_USER={state.user} · SMTP_FROM={state.from}
</p>
</div>
</div>
) : null}
</div>
</div>
);
}
+4 -2
View File
@@ -49,14 +49,15 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der // Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder // Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren, // abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen. // offene Widerrufe, unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
const brand = await getBrandAssets(); const brand = await getBrandAssets();
const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value); const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value);
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([ const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([
prisma.order.count({ prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null }, where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
}), }),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }), prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.withdrawalDeclaration.count({ where: { processedAt: null } }),
prisma.review.count({ where: { isApproved: false } }), prisma.review.count({ where: { isApproved: false } }),
prisma.businessInquiry.count({ where: { status: "NEU" } }), prisma.businessInquiry.count({ where: { status: "NEU" } }),
]); ]);
@@ -69,6 +70,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
badges={{ badges={{
"/admin/bestellungen": openOrders, "/admin/bestellungen": openOrders,
"/admin/retouren": openReturns, "/admin/retouren": openReturns,
"/admin/widerrufe": openWithdrawals,
"/admin/bewertungen": pendingReviews, "/admin/bewertungen": pendingReviews,
"/admin/anfragen": newInquiries, "/admin/anfragen": newInquiries,
}} }}
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { fail, succeed } from "@/lib/flash";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/app/admin/actions";
import { logAdminAction } from "@/lib/audit-log";
const PATH = "/admin/widerrufe";
/** Markiert eine Widerrufserklaerung als erledigt bzw. oeffnet sie wieder. Die Erklaerung selbst bleibt unveraendert — sie ist Beleg. */
export async function setWithdrawalProcessedAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = String(formData.get("id") ?? "").trim();
const processed = formData.get("processed") === "1";
const declaration = await prisma.withdrawalDeclaration.findUnique({ where: { id }, select: { orderNumber: true } });
if (!declaration) return fail(PATH, "Dieser Widerruf existiert nicht mehr.");
await prisma.withdrawalDeclaration.update({
where: { id },
data: { processedAt: processed ? new Date() : null },
});
await logAdminAction(admin, {
action: processed ? "withdrawal.processed" : "withdrawal.reopened",
entityType: "WithdrawalDeclaration",
entityId: id,
detail: declaration.orderNumber,
});
revalidatePath(PATH);
revalidatePath("/admin", "layout");
return succeed(PATH, `Widerruf zu ${declaration.orderNumber} ${processed ? "als erledigt markiert" : "wieder geöffnet"}.`);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminBadge, AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
import { formatDate } from "@/lib/format";
import { formatReceivedAt } from "@/lib/withdrawal";
import { setWithdrawalProcessedAction } from "./actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
const MAX_ROWS = 200;
export default async function AdminWiderrufePage() {
const locale = await getLocale();
const t = getDictionary(locale);
const s = t.adminWithdrawalsPage;
// Offene zuerst (aelteste offene oben: deren Frist laeuft zuerst ab), dann erledigte.
const [open, done] = await Promise.all([
prisma.withdrawalDeclaration.findMany({ where: { processedAt: null }, orderBy: { receivedAt: "asc" }, take: MAX_ROWS }),
prisma.withdrawalDeclaration.findMany({ where: { processedAt: { not: null } }, orderBy: { receivedAt: "desc" }, take: MAX_ROWS }),
]);
const declarations = [...open, ...done];
return (
<div>
<BackLink href="/admin" label={s.backToDashboard} />
<AdminPageHeader title={s.title} />
<p className="mb-8 max-w-2xl text-sm text-ink/70">{s.intro}</p>
{declarations.length === 0 ? <AdminEmptyState message={s.empty} /> : null}
<div className="space-y-4">
{declarations.map((d) => (
<div key={d.id} className="border border-ink/10 bg-background p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-heading">{d.orderNumber}</p>
<p className="text-sm text-ink/70">
{d.name} · {d.email}
</p>
<p className="mt-1 text-xs tabular-nums text-ink/65">
{s.receivedAt}: {formatReceivedAt(d.receivedAt, locale)}
</p>
</div>
<AdminBadge tone={d.processedAt ? "positive" : "pending"} pulse={!d.processedAt}>
{d.processedAt ? s.statusProcessed : s.statusOpen}
</AdminBadge>
</div>
{d.message ? <p className="mt-4 whitespace-pre-line text-sm text-ink/80">{d.message}</p> : null}
<ul className="mt-4 space-y-1 text-xs">
<li className={d.orderId ? "text-ink/70" : "text-red-700 dark:text-red-300"}>
{d.orderId ? s.matched : s.unmatched}
</li>
<li className={d.confirmationSentAt ? "text-ink/70" : "text-red-700 dark:text-red-300"}>
{d.confirmationSentAt ? s.mailSent : s.mailNotSent}
</li>
{d.processedAt ? <li className="text-ink/70">{s.processedOn(formatDate(d.processedAt))}</li> : null}
</ul>
<div className="mt-5 flex flex-wrap items-center gap-4">
<form action={setWithdrawalProcessedAction}>
<input type="hidden" name="id" value={d.id} />
<input type="hidden" name="processed" value={d.processedAt ? "0" : "1"} />
<Button type="submit" variant={d.processedAt ? "ghost" : "primary"} className="px-4 py-2 text-xs">
{d.processedAt ? s.reopen : s.markProcessed}
</Button>
</form>
{d.orderId ? (
<Link href={`/admin/bestellungen/${d.orderId}`} className="link-draw pb-0.5 text-sm text-heading">
{s.viewOrder}
</Link>
) : null}
</div>
</div>
))}
</div>
</div>
);
}
+3 -1
View File
@@ -16,7 +16,9 @@ export async function GET(request: Request) {
await prisma.newsletterSubscriber.update({ await prisma.newsletterSubscriber.update({
where: { id: subscriber.id }, where: { id: subscriber.id },
data: { confirmed: true, unsubscribedAt: null }, // confirmedAt belegt die Einwilligung (Art. 7 Abs. 1 DSGVO). Jede erneute
// Bestaetigung ueberschreibt ihn, weil erst sie die aktuelle Einwilligung ist.
data: { confirmed: true, confirmedAt: new Date(), unsubscribedAt: null },
}); });
await ensureNewsletterDiscountCode(); await ensureNewsletterDiscountCode();
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { withdrawalSchema } from "@/lib/validation";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
import { isTrustedOrigin } from "@/lib/csrf";
import { sendMail } from "@/lib/mail";
import { withdrawalReceiptEmail } from "@/lib/email-templates";
import { formatReceivedAt, withdrawalDeclarationLines } from "@/lib/withdrawal";
import { escapeHtml } from "@/lib/escape-html";
import { getLocale } from "@/i18n/locale";
const WITHDRAWALS_PER_WINDOW = 10;
const WINDOW_MS = 60 * 60 * 1000;
/**
* Elektronische Widerrufsfunktion (§ 356a BGB), zweite Stufe "Widerruf
* bestaetigen". Bewusst OHNE Turnstile: eine fehlschlagende Pruefung duerfte
* die Ausuebung eines gesetzlichen Rechts nicht verhindern. Missbrauch
* begrenzen Herkunftspruefung und Rate-Limit.
*
* Die Antwort ist fuer passende und unpassende Bestellnummern identisch —
* sonst liessen sich Bestellnummern samt E-Mail-Adresse durchprobieren. Die
* Erklaerung wird in jedem Fall gespeichert: sie ist mit dem Zugang wirksam
* abgegeben, die Zuordnung zur Bestellung ist unsere Aufgabe (Admin).
*/
export async function POST(request: Request) {
if (!isTrustedOrigin(request)) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
}
const ip = await getClientIp();
if (!rateLimit(`withdrawal:${ip}`, WITHDRAWALS_PER_WINDOW, WINDOW_MS)) {
return NextResponse.json(
{ error: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine E-Mail." },
{ status: 429 }
);
}
const body: unknown = await request.json().catch(() => null);
const parsed = withdrawalSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() },
{ status: 400 }
);
}
const input = parsed.data;
const locale = await getLocale();
const order = await prisma.order.findUnique({
where: { orderNumber: input.orderNumber },
select: { id: true, email: true },
});
const orderId = order && order.email.trim().toLowerCase() === input.email ? order.id : null;
const declaration = await prisma.withdrawalDeclaration.create({
data: { ...input, locale, orderId },
});
const receipt = await withdrawalReceiptEmail(input, declaration.receivedAt, locale);
const confirmationSent = await sendMail({ to: input.email, ...receipt });
if (confirmationSent) {
await prisma.withdrawalDeclaration.update({
where: { id: declaration.id },
data: { confirmationSentAt: new Date() },
});
}
// Interne Benachrichtigung: ein Widerruf hat eine 14-Tage-Frist fuer die
// Erstattung und darf nicht bis zum naechsten Blick ins Admin liegen bleiben.
const adminEmail = process.env.ADMIN_EMAIL;
if (adminEmail) {
const noticeLines = [
...withdrawalDeclarationLines(input, "de"),
"",
`Eingegangen am: ${formatReceivedAt(declaration.receivedAt, "de")}`,
orderId ? "Automatisch der Bestellung zugeordnet." : "Keine passende Bestellung gefunden — bitte manuell prüfen.",
`Eingangsbestätigung an Kundschaft: ${confirmationSent ? "versendet" : "NICHT versendet"}`,
];
await sendMail({
to: adminEmail,
subject: `Neuer Widerruf zu ${input.orderNumber.replace(/[\r\n]+/g, " ")}`,
text: noticeLines.join("\n"),
html: `<p style="font-family:Arial,sans-serif;line-height:1.6;">${noticeLines.map(escapeHtml).join("<br>")}</p>`,
});
}
return NextResponse.json(
{
receivedAt: formatReceivedAt(declaration.receivedAt, locale),
lines: withdrawalDeclarationLines(input, locale),
confirmationSent,
},
{ headers: { "Cache-Control": "no-store" } }
);
}
+2 -10
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LegalPage } from "@/components/legal-page"; import { LegalPage } from "@/components/legal-page";
import { CookieSettingsButton } from "@/components/cookie-consent"; import { CookieSettingsButton } from "@/components/cookie-consent";
import { Container } from "@/components/ui";
import { getContentValue, parseSections } from "@/lib/content"; import { getContentValue, parseSections } from "@/lib/content";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -13,15 +12,8 @@ export default async function CookieRichtliniePage() {
const t = getDictionary(locale); const t = getDictionary(locale);
const sections = parseSections(await getContentValue("legal.cookieRichtlinie", locale)); const sections = parseSections(await getContentValue("legal.cookieRichtlinie", locale));
return ( return (
<> <LegalPage title={t.cookieRichtliniePage.title} noticeText={t.legalPage.reviewNotice} sections={sections}>
<LegalPage
title={t.cookieRichtliniePage.title}
noticeText={t.legalPage.reviewNotice}
sections={sections}
/>
<Container className="max-w-3xl pb-20 -mt-10">
<CookieSettingsButton /> <CookieSettingsButton />
</Container> </LegalPage>
</>
); );
} }
+3 -2
View File
@@ -20,11 +20,12 @@ export async function toggleNewsletterAction(): Promise<void> {
} else if (existing) { } else if (existing) {
await prisma.newsletterSubscriber.update({ await prisma.newsletterSubscriber.update({
where: { id: existing.id }, where: { id: existing.id },
data: { unsubscribedAt: null, confirmed: true }, // Der Klick im eigenen Konto ist die Einwilligung — Zeitpunkt als Nachweis.
data: { unsubscribedAt: null, confirmed: true, confirmedAt: new Date() },
}); });
} else { } else {
await prisma.newsletterSubscriber.create({ await prisma.newsletterSubscriber.create({
data: { email: customer.email, customerId: customer.id, confirmed: true }, data: { email: customer.email, customerId: customer.id, confirmed: true, confirmedAt: new Date() },
}); });
} }
+11
View File
@@ -154,6 +154,17 @@ export default async function KontoBestellungenPage() {
{t.ordersPage.requestReturn} {t.ordersPage.requestReturn}
</Link> </Link>
) : null} ) : null}
{RETURN_ELIGIBLE_STATUSES.includes(order.status) ? (
// Elektronische Widerrufsfunktion (§ 356a BGB) mit
// vorausgefuellter Bestellnummer — die Rueckgabe oben ist
// ein Service, der Widerruf das gesetzliche Recht.
<Link
href={`/vertrag-widerrufen?bestellung=${encodeURIComponent(order.orderNumber)}`}
className="text-sm text-heading underline"
>
{t.withdrawalPage.footerButton}
</Link>
) : null}
<ReorderButton <ReorderButton
items={order.items.map((item) => ({ items={order.items.map((item) => ({
productId: item.productId, productId: item.productId,
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { Container, SectionHeading, FormPanel } from "@/components/ui";
import { WithdrawalForm } from "@/components/withdrawal-form";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Vertrag widerrufen", alternates: { canonical: "/vertrag-widerrufen" } };
/** Nur Zeichen, die in Bestellnummern vorkommen — alles andere wird nicht vorausgefuellt. */
const ORDER_NUMBER_PREFILL = /^[A-Za-z0-9-]{1,40}$/;
/**
* Elektronische Widerrufsfunktion (§ 356a BGB). Ohne Anmeldung erreichbar —
* auch Gastbestellungen muessen hier widerrufen werden koennen. Aus dem
* Kundenkonto kommt man mit ?bestellung=… und vorausgefuellter Nummer.
*/
export default async function VertragWiderrufenPage(props: PageProps<"/vertrag-widerrufen">) {
const t = getDictionary(await getLocale());
const { bestellung } = await props.searchParams;
const prefill = typeof bestellung === "string" && ORDER_NUMBER_PREFILL.test(bestellung) ? bestellung : "";
return (
<div className="py-20">
<Container className="max-w-xl">
<FormPanel>
<SectionHeading title={t.withdrawalPage.title} level="h1" />
<p className="mt-6 text-sm leading-relaxed text-ink/75">{t.withdrawalPage.intro}</p>
<div className="mt-8">
<WithdrawalForm defaultOrderNumber={prefill} />
</div>
</FormPanel>
</Container>
</div>
);
}
+17 -7
View File
@@ -1,20 +1,30 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LegalPage } from "@/components/legal-page"; import { LegalPage } from "@/components/legal-page";
import { getContentValue, parseSections } from "@/lib/content"; import { getContentValue, parseSections } from "@/lib/content";
import { getCompanyInfo } from "@/lib/company-info";
import { getSiteUrl } from "@/lib/url";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export const metadata: Metadata = { title: "Widerrufsbelehrung", alternates: { canonical: "/widerrufsbelehrung" } }; export const metadata: Metadata = { title: "Widerrufsbelehrung", alternates: { canonical: "/widerrufsbelehrung" } };
/**
* Platzhalter im Belehrungstext fuer Name, Anschrift und E-Mail des
* Empfaengers. Das amtliche Muster verlangt diese Angaben an zwei Stellen
* (Belehrung und Formular); aus den Firmendaten eingesetzt, bleiben sie bei
* einem Umzug oder neuer Adresse automatisch richtig.
*/
const PROVIDER_PLACEHOLDER = "{{anbieter}}";
/** Adresse der elektronischen Widerrufsfunktion (§ 356a BGB, app/vertrag-widerrufen). */
const WITHDRAWAL_FUNCTION_PLACEHOLDER = "{{widerrufsfunktion}}";
export default async function WiderrufPage() { export default async function WiderrufPage() {
const locale = await getLocale(); const locale = await getLocale();
const t = getDictionary(locale); const t = getDictionary(locale);
const sections = parseSections(await getContentValue("legal.widerruf", locale)); const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.widerruf", locale)]);
return ( const provider = `${company.name}, ${company.address.street}, ${company.address.zip} ${company.address.city}, E-Mail: ${company.email}`;
<LegalPage const sections = parseSections(
title={t.widerrufPage.title} raw.replaceAll(PROVIDER_PLACEHOLDER, provider).replaceAll(WITHDRAWAL_FUNCTION_PLACEHOLDER, `${getSiteUrl()}/vertrag-widerrufen`)
noticeText={t.legalPage.reviewNotice}
sections={sections}
/>
); );
return <LegalPage title={t.widerrufPage.title} noticeText={t.legalPage.reviewNotice} sections={sections} />;
} }
+2
View File
@@ -33,6 +33,7 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte
items: [ items: [
{ href: "/admin/bestellungen", label: t.adminShell.navOrders }, { href: "/admin/bestellungen", label: t.adminShell.navOrders },
{ href: "/admin/retouren", label: t.adminShell.navReturns }, { href: "/admin/retouren", label: t.adminShell.navReturns },
{ href: "/admin/widerrufe", label: t.adminShell.navWithdrawals },
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers }, { href: "/admin/gutscheine", label: t.adminShell.navVouchers },
{ href: "/admin/versand", label: t.adminShell.navShipping }, { href: "/admin/versand", label: t.adminShell.navShipping },
], ],
@@ -58,6 +59,7 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte
items: [ items: [
{ href: "/admin/inhalte", label: t.adminShell.navContent }, { href: "/admin/inhalte", label: t.adminShell.navContent },
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog }, { href: "/admin/audit-log", label: t.adminShell.navAuditLog },
{ href: "/admin/e-mail-test", label: t.adminShell.navEmailTest },
], ],
}, },
]; ];
+6 -3
View File
@@ -6,10 +6,9 @@ import { HoneycombPattern } from "@/components/honeycomb";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider"; import { useI18n } from "@/i18n/i18n-provider";
import { COOKIE_CONSENT_KEY, clearOptionalStorage, type Consent } from "@/lib/consent";
export const COOKIE_CONSENT_KEY = "wabenhain-cookie-consent"; export { COOKIE_CONSENT_KEY };
type Consent = "all" | "essential";
export function CookieConsentBanner() { export function CookieConsentBanner() {
const { t } = useI18n(); const { t } = useI18n();
@@ -25,6 +24,7 @@ export function CookieConsentBanner() {
function choose(consent: Consent) { function choose(consent: Consent) {
window.localStorage.setItem(COOKIE_CONSENT_KEY, consent); window.localStorage.setItem(COOKIE_CONSENT_KEY, consent);
if (consent === "essential") clearOptionalStorage();
setVisible(false); setVisible(false);
} }
@@ -76,7 +76,10 @@ export function CookieSettingsButton() {
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
// Widerruf (Art. 7 Abs. 3 DSGVO): einwilligungspflichtige Daten sofort
// entfernen, nicht erst bei der naechsten Auswahl.
window.localStorage.removeItem(COOKIE_CONSENT_KEY); window.localStorage.removeItem(COOKIE_CONSENT_KEY);
clearOptionalStorage();
window.location.reload(); window.location.reload();
}} }}
> >
+182 -17
View File
@@ -1,45 +1,210 @@
import type { ReactNode } from "react";
import { Container } from "@/components/ui"; import { Container } from "@/components/ui";
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
import { LegalToc } from "@/components/legal-toc";
import { ReadingProgress } from "@/components/reading-progress";
import { extractRevision, parseLegalParagraph, sectionAnchors, tokenizeInline, type LegalBlock } from "@/lib/legal-blocks";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export interface LegalSection { export interface LegalSection {
heading: string; heading: string;
paragraphs: string[]; paragraphs: string[];
} }
/** Ab so vielen Abschnitten lohnt ein Inhaltsverzeichnis; darunter waere es nur Laerm. */
const MIN_SECTIONS_FOR_TOC = 3;
/** /**
* Gemeinsames Layout fuer alle rechtlichen Seiten (Impressum, Datenschutz, * Gemeinsames Layout fuer alle rechtlichen Seiten (Impressum, Datenschutz,
* AGB, Widerrufsbelehrung, Versand und Zahlung). Inhalte sind Platzhalter * AGB, Widerrufsbelehrung, Versand und Zahlung, Cookie-Richtlinie). Der Text
* und muessen vor Live-Gang durch anwaltlich geprueften Text ersetzt werden. * bleibt Klartext aus dem Admin-Bereich; lib/legal-blocks.ts erkennt darin
* Aufzaehlungen, Cookie-/Anbieter-Eintraege, Links und das "Stand"-Datum.
*/ */
export function LegalPage({ export async function LegalPage({
title, title,
noticeText, noticeText,
sections, sections: rawSections,
children,
}: { }: {
title: string; title: string;
/** Optional — leer lassen, wenn kein Hinweis (mehr) angezeigt werden soll. */ /** Optional — leer lassen, wenn kein Hinweis (mehr) angezeigt werden soll. */
noticeText: string; noticeText: string;
sections: LegalSection[]; sections: LegalSection[];
/** Zusaetzliche Bedienelemente unter dem Text (z. B. Cookie-Einstellungen). */
children?: ReactNode;
}) { }) {
const locale = await getLocale();
const t = getDictionary(locale);
const { sections, revision } = extractRevision(rawSections);
const anchors = sectionAnchors(sections.map((s) => s.heading));
const showToc = sections.length >= MIN_SECTIONS_FOR_TOC;
return ( return (
<div className="py-20"> <div className="pb-24">
<Container className="max-w-3xl"> <ReadingProgress />
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-heading">{title}</h1> <header className="relative overflow-hidden border-b border-ink/10 bg-surface/60">
{noticeText ? ( <HoneycombPattern className="text-honey/[0.16]" size={28} fade="right" />
<p className="mt-2 text-xs text-honey-dark tracking-wide-caps">{noticeText}</p> <Container className="relative py-16 md:py-24">
{/* Lange Einzelwoerter ("Widerrufsbelehrung", "Datenschutzerklärung")
passen auf 390 px nicht in 3rem — kleiner starten und notfalls
an Silbengrenzen trennen statt abzuschneiden. */}
<h1 lang={locale} className="max-w-3xl break-words text-[2.5rem] leading-[1.02] tracking-[-0.02em] text-heading [hyphens:auto] sm:text-5xl md:text-7xl md:leading-[0.98]">
{title}
</h1>
{revision || noticeText ? (
<div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-ink/70">
{revision ? (
<p className="flex items-center gap-2">
<HexMark className="h-2.5 w-2.5 text-honey" />
<span>
{t.legalPage.revisionLabel}: <span className="tabular-nums text-heading">{revision}</span>
</span>
</p>
) : null} ) : null}
<div className="mt-10 space-y-10"> {noticeText ? <p className="text-honey-dark">{noticeText}</p> : null}
{sections.map((section) => ( </div>
<section key={section.heading}> ) : null}
<h2 className="text-2xl text-heading">{section.heading}</h2> </Container>
<div className="mt-3 space-y-3 text-sm text-ink/70 leading-relaxed"> </header>
{section.paragraphs.map((p, i) => (
<p key={i}>{p}</p> <Container className="pt-12 md:pt-16">
))} <div className={showToc ? "lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-20" : undefined}>
{showToc ? (
<LegalToc label={t.legalPage.tocLabel} items={sections.map((s, i) => ({ id: anchors[i], label: s.heading }))} />
) : null}
<div className="max-w-[68ch]">
{sections.map((section, i) => (
<section
key={anchors[i]}
id={anchors[i]}
aria-labelledby={`${anchors[i]}-heading`}
className="scroll-mt-28 border-t border-ink/10 pt-10 first:border-t-0 first:pt-0 [&+&]:mt-12"
>
<SectionHeading id={`${anchors[i]}-heading`} text={section.heading} />
<div className="mt-5 space-y-4 text-[0.975rem] leading-[1.75] text-ink/80">
<Blocks blocks={section.paragraphs.map(parseLegalParagraph)} />
</div> </div>
</section> </section>
))} ))}
{children ? <div className="mt-14 border-t border-ink/10 pt-10">{children}</div> : null}
</div>
</div> </div>
</Container> </Container>
</div> </div>
); );
} }
/** "3. Server-Logfiles" → die Nummer leise in Honig, der Titel in der Serife. */
function SectionHeading({ id, text }: { id: string; text: string }) {
const numbered = /^(\d+)\.\s+(.+)$/.exec(text);
return (
<h2 id={id} className="text-3xl leading-tight text-heading md:text-[2.1rem]">
{numbered ? (
<>
<span className="mr-3 align-[0.35em] font-sans text-sm tabular-nums tracking-normal text-honey-dark">{numbered[1].padStart(2, "0")}</span>
{numbered[2]}
</>
) : (
text
)}
</h2>
);
}
/** Aufeinanderfolgende Eintraege bilden eine gemeinsame Tabelle mit Trennlinien. */
function Blocks({ blocks }: { blocks: LegalBlock[] }) {
const groups: (LegalBlock | LegalBlock[])[] = [];
for (const block of blocks) {
const previous = groups[groups.length - 1];
if (block.kind === "entry" && Array.isArray(previous)) previous.push(block);
else groups.push(block.kind === "entry" ? [block] : block);
}
return groups.map((group, i) => {
if (Array.isArray(group)) {
return (
<div key={i} className="!mt-7 divide-y divide-ink/10 border-y border-ink/10 [&+*]:!mt-7">
{group.map((entry, j) => (entry.kind === "entry" ? <Entry key={j} entry={entry} /> : null))}
</div>
);
}
if (group.kind === "list") {
return (
<ul key={i} className="space-y-2">
{group.items.map((item, j) => (
<li key={j} className="flex gap-3">
<HexMark className="mt-[0.55rem] h-2 w-2 flex-shrink-0 text-honey" />
<span>
<Inline text={item} />
</span>
</li>
))}
</ul>
);
}
if (group.kind === "text") {
return (
<p key={i} className="whitespace-pre-line">
<Inline text={group.text} />
</p>
);
}
return null;
});
}
function Entry({ entry }: { entry: Extract<LegalBlock, { kind: "entry" }> }) {
return (
<div className="grid gap-x-8 gap-y-2 py-5 sm:grid-cols-[11.5rem_minmax(0,1fr)]">
<div>
{entry.note ? (
<>
<p className="font-serif text-xl leading-tight text-heading">{entry.name}</p>
<p className="mt-1 text-xs text-ink/60">{entry.note}</p>
</>
) : (
// Technischer Cookie-/Speichername: Monoschrift, weil es ein
// wortwoertlich zu findender Bezeichner ist (Browser-Entwicklertools).
<code className="block break-all pt-[0.3rem] font-mono text-[0.78rem] leading-5 text-heading">{entry.name}</code>
)}
</div>
<div>
{entry.text ? (
<p>
<Inline text={entry.text} />
</p>
) : null}
{entry.facts.length > 0 ? (
<dl className="mt-3 grid gap-x-5 gap-y-1.5 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
{entry.facts.map((fact) => (
<div key={fact.label} className="contents">
<dt className="pt-[0.2rem] text-[0.68rem] tracking-wide-caps text-honey-dark">{fact.label}</dt>
<dd className="mb-1.5 text-ink/80 sm:mb-0">
<Inline text={fact.value} />
</dd>
</div>
))}
</dl>
) : null}
</div>
</div>
);
}
function Inline({ text }: { text: string }) {
return tokenizeInline(text).map((token, i) =>
token.kind === "link" ? (
<a
key={i}
href={token.href}
{...(token.href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="text-heading underline decoration-honey/60 decoration-1 underline-offset-[0.22em] transition-colors hover:decoration-honey"
>
{token.value}
</a>
) : (
<span key={i}>{token.value}</span>
)
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { HexMark } from "@/components/honeycomb";
export interface LegalTocItem {
id: string;
label: string;
}
/**
* Inhaltsverzeichnis der Rechtsseiten. Breit: klebt neben dem Text und
* markiert den Abschnitt, in dem man gerade liest — das Sechseck gleitet
* mit (die eine bewusste Bewegung der Seite). Schmal: aufklappbar ueber dem
* Text, damit er nicht erst nach einer langen Linkliste beginnt.
*/
export function LegalToc({ items, label }: { items: LegalTocItem[]; label: string }) {
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
const reduceMotion = useReducedMotion();
useEffect(() => {
const targets = items.map((item) => document.getElementById(item.id)).filter((el): el is HTMLElement => el !== null);
// Das Band liegt im oberen Drittel: aktiv ist der Abschnitt, dessen
// Ueberschrift man gerade liest, nicht der, der unten hereinragt.
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]) setActiveId(visible[0].target.id);
},
{ rootMargin: "-15% 0px -70% 0px" }
);
targets.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [items]);
const links = (onDesktop: boolean) => (
<ol className="space-y-0.5">
{items.map((item) => {
const isActive = onDesktop && item.id === activeId;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={isActive ? "location" : undefined}
className={`group relative flex items-start gap-3 py-1.5 text-sm leading-snug transition-colors duration-300 ${
isActive ? "text-heading" : "text-ink/60 hover:text-heading"
}`}
>
<span className="relative mt-[0.3rem] h-2.5 w-2.5 flex-shrink-0">
<HexMark className="absolute inset-0 h-2.5 w-2.5 text-ink/15" filled={false} />
{isActive ? (
<motion.span
layoutId="legal-toc-marker"
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 36 }}
className="absolute inset-0 text-honey"
>
<HexMark className="h-2.5 w-2.5" />
</motion.span>
) : null}
</span>
<span>{item.label}</span>
</a>
</li>
);
})}
</ol>
);
return (
<>
<details className="group/toc mb-12 border-y border-ink/10 lg:hidden">
<summary className="flex cursor-pointer list-none items-center justify-between py-4 text-sm tracking-wide-caps text-heading [&::-webkit-details-marker]:hidden">
{label}
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4 transition-transform duration-300 group-open/toc:rotate-180" fill="none" stroke="currentColor" strokeWidth="1.6">
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</summary>
<nav aria-label={label} className="pb-5">
{links(false)}
</nav>
</details>
<nav aria-label={label} className="sticky top-28 hidden max-h-[calc(100vh-9rem)] self-start overflow-y-auto pr-2 lg:block">
<p className="mb-4 text-xs tracking-wide-caps text-ink/60">{label}</p>
{links(true)}
</nav>
</>
);
}
+7 -1
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { HoneycombPattern } from "@/components/honeycomb"; import { HoneycombPattern } from "@/components/honeycomb";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Container } from "@/components/ui"; import { Container, LinkButton } from "@/components/ui";
import { Logomark } from "@/components/logomark"; import { Logomark } from "@/components/logomark";
import { footerNav } from "@/lib/site-config"; import { footerNav } from "@/lib/site-config";
import type { SocialLinks } from "@/lib/social-links"; import type { SocialLinks } from "@/lib/social-links";
@@ -139,6 +139,12 @@ export async function Footer({
<br /> <br />
{companyInfo.email} {companyInfo.email}
</p> </p>
{/* Elektronische Widerrufsfunktion (§ 356a BGB): auf jeder Seite
erreichbar und hervorgehoben — deshalb ein eigener Knopf statt
eines weiteren Eintrags in der Rechtstext-Spalte. */}
<LinkButton href="/vertrag-widerrufen" variant="inverted" className="mt-6 px-5 py-2.5 text-xs">
{t.withdrawalPage.footerButton}
</LinkButton>
</div> </div>
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} /> <FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
<FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} /> <FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} />
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import { HexCheck } from "@/components/honeycomb";
import { useI18n } from "@/i18n/i18n-provider";
interface WithdrawalValues {
name: string;
orderNumber: string;
email: string;
message: string;
}
interface WithdrawalResult {
receivedAt: string;
lines: string[];
confirmationSent: boolean;
}
type Step = "form" | "review" | "sending" | "done";
/**
* Elektronische Widerrufsfunktion nach § 356a BGB in den zwei vom Gesetz
* verlangten Stufen: "Vertrag widerrufen" (Angaben) → "Widerruf bestaetigen"
* (Absenden). Erst die zweite Stufe schickt etwas an den Server.
*/
export function WithdrawalForm({ defaultOrderNumber = "" }: { defaultOrderNumber?: string }) {
const { t } = useI18n();
const s = t.withdrawalPage;
const [step, setStep] = useState<Step>("form");
const [values, setValues] = useState<WithdrawalValues>({ name: "", orderNumber: defaultOrderNumber, email: "", message: "" });
const [result, setResult] = useState<WithdrawalResult | null>(null);
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const isFirstRender = useRef(true);
// Beim Stufenwechsel den Fokus auf die neue Ueberschrift setzen, damit
// Screenreader und Tastatur den Wechsel mitbekommen.
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
headingRef.current?.focus();
}, [step]);
function handleStart(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setValues({
name: String(data.get("name") ?? "").trim(),
orderNumber: String(data.get("orderNumber") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
});
setError(null);
setStep("review");
}
async function handleConfirm() {
setStep("sending");
setError(null);
try {
const response = await fetch("/api/widerruf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...values, message: values.message || undefined }),
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok || !body || typeof body !== "object" || !("receivedAt" in body)) {
throw new Error("request failed");
}
setResult(body as WithdrawalResult);
setStep("done");
} catch {
setError(s.errorMessage);
setStep("review");
}
}
if (step === "done" && result) {
return (
<div aria-live="polite">
<div className="flex items-center gap-3 text-honey-dark">
<HexCheck className="h-7 w-7" />
<h2 ref={headingRef} tabIndex={-1} className="text-3xl text-heading outline-none">
{s.successHeading}
</h2>
</div>
<Declaration lines={result.lines} />
<p className="mt-5 text-sm text-ink/80">
<span className="text-xs tracking-wide-caps text-honey-dark">{s.receivedAtLabel}</span>
<br />
<span className="tabular-nums text-heading">{result.receivedAt}</span>
</p>
<p className="mt-5 text-sm leading-relaxed text-ink/80">{result.confirmationSent ? s.successMailSent : s.successMailFailed}</p>
</div>
);
}
if (step === "review" || step === "sending") {
return (
<div>
<h2 ref={headingRef} tabIndex={-1} className="text-3xl text-heading outline-none">
{s.reviewHeading}
</h2>
<p className="mt-3 text-sm text-ink/75">{s.reviewIntro}</p>
<Declaration
lines={[
`${s.orderNumberLabel}: ${values.orderNumber}`,
`${s.nameLabel}: ${values.name}`,
`${s.emailLabel}: ${values.email}`,
...(values.message ? [`${s.messageLabel}: ${values.message}`] : []),
]}
/>
{error ? (
<p role="alert" className="mt-5 text-sm text-red-700 dark:text-red-300">
{error}
</p>
) : null}
<div className="mt-7 flex flex-wrap gap-3">
<Button type="button" onClick={handleConfirm} disabled={step === "sending"}>
{step === "sending" ? s.submitting : s.confirmButton}
</Button>
<Button type="button" variant="secondary" onClick={() => setStep("form")} disabled={step === "sending"}>
{s.editButton}
</Button>
</div>
</div>
);
}
return (
<form onSubmit={handleStart} className="space-y-5">
<Field name="name" label={s.nameLabel} autoComplete="name" defaultValue={values.name} maxLength={120} required />
<Field
name="orderNumber"
label={s.orderNumberLabel}
hint={s.orderNumberHint}
defaultValue={values.orderNumber}
maxLength={40}
required
/>
<Field
name="email"
type="email"
label={s.emailLabel}
hint={s.emailHint}
autoComplete="email"
defaultValue={values.email}
maxLength={254}
required
/>
<label className="block text-sm">
<span className="text-ink/70">{s.messageLabel}</span>
<textarea
name="message"
rows={3}
maxLength={2000}
defaultValue={values.message}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:border-heading focus:outline-none"
/>
</label>
<p className="text-xs text-ink/60">{s.requiredNote}</p>
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<Button type="submit">{s.startButton}</Button>
<Link href="/widerrufsbelehrung" className="link-draw pb-0.5 text-sm text-heading">
{s.policyLink}
</Link>
</div>
</form>
);
}
function Declaration({ lines }: { lines: string[] }) {
return (
<div className="mt-5 border-y border-ink/10 bg-surface/60 px-5 py-4 text-sm leading-relaxed text-ink/85">
{lines.map((line, i) => (
<p key={i} className={i === 0 ? "text-heading" : undefined}>
{line}
</p>
))}
</div>
);
}
function Field({
name,
label,
hint,
type = "text",
...inputProps
}: {
name: string;
label: string;
hint?: string;
type?: string;
autoComplete?: string;
defaultValue?: string;
maxLength?: number;
required?: boolean;
}) {
const hintId = hint ? `${name}-hint` : undefined;
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
aria-describedby={hintId}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:border-heading focus:outline-none"
{...inputProps}
/>
{hint ? (
<span id={hintId} className="mt-1 block text-xs text-ink/60">
{hint}
</span>
) : null}
</label>
);
}
+134 -26
View File
@@ -170,6 +170,32 @@ export const de: Dictionary = {
}, },
legalPage: { legalPage: {
reviewNotice: "", reviewNotice: "",
tocLabel: "Inhalt",
revisionLabel: "Stand",
},
withdrawalPage: {
title: "Vertrag widerrufen",
intro: "Hier kannst du deinen Kaufvertrag innerhalb der 14-tägigen Widerrufsfrist online widerrufen. Du brauchst dafür kein Kundenkonto und musst keinen Grund angeben.",
nameLabel: "Dein Name",
orderNumberLabel: "Bestellnummer",
orderNumberHint: "Steht in deiner Bestellbestätigung, z. B. WH-2026-0042.",
emailLabel: "E-Mail-Adresse",
emailHint: "An diese Adresse schicken wir die Eingangsbestätigung.",
messageLabel: "Nachricht (freiwillig)",
requiredNote: "Pflichtangaben sind nur Name, Bestellnummer und E-Mail-Adresse.",
startButton: "Vertrag widerrufen",
reviewHeading: "Bitte prüfe deinen Widerruf",
reviewIntro: "Mit dem Klick auf „Widerruf bestätigen“ senden wir diese Erklärung ab:",
confirmButton: "Widerruf bestätigen",
editButton: "Angaben ändern",
submitting: "Wird gesendet …",
successHeading: "Dein Widerruf ist eingegangen",
successMailSent: "Wir haben dir soeben eine Eingangsbestätigung mit dem Inhalt deiner Erklärung sowie Datum und Uhrzeit des Eingangs per E-Mail geschickt.",
successMailFailed: "Dein Widerruf ist wirksam bei uns eingegangen. Die Eingangsbestätigung per E-Mail konnte gerade nicht versendet werden; wir schicken sie dir schnellstmöglich nach. Bitte speichere diese Seite als Nachweis.",
receivedAtLabel: "Eingegangen am",
errorMessage: "Dein Widerruf konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine E-Mail — auch ein Widerruf per E-Mail oder Brief ist wirksam.",
policyLink: "Zur Widerrufsbelehrung",
footerButton: "Vertrag widerrufen",
}, },
notFoundPage: { notFoundPage: {
title: "Diese Seite gibt es nicht, zumindest nicht mehr.", title: "Diese Seite gibt es nicht, zumindest nicht mehr.",
@@ -186,7 +212,7 @@ export const de: Dictionary = {
cookieConsent: { cookieConsent: {
dialogLabel: "Cookie-Einstellungen", dialogLabel: "Cookie-Einstellungen",
bannerTextBeforeLink: bannerTextBeforeLink:
"Wir verwenden nur technisch notwendige Cookies (z.B. für den Login im Kundenkonto). Mit deiner Zustimmung nutzen wir zusätzlich Cookies zur Reichweitenmessung. Details findest du in unserer ", "Wir verwenden technisch notwendige Cookies (z. B. für den Login im Kundenkonto). Mit deiner Zustimmung merken wir uns außerdem in deinem Browser, welche Produkte du zuletzt angesehen hast. Tracking oder Werbung gibt es bei uns nicht. Details findest du in unserer ",
policyLinkLabel: "Cookie-Richtlinie", policyLinkLabel: "Cookie-Richtlinie",
bannerTextAfterLink: ".", bannerTextAfterLink: ".",
essentialOnly: "Nur notwendige", essentialOnly: "Nur notwendige",
@@ -200,7 +226,7 @@ export const de: Dictionary = {
emailLabel: "E-Mail", emailLabel: "E-Mail",
sections: [ sections: [
{ {
heading: "Angaben gemäß § 5 TMG", heading: "Angaben gemäß § 5 DDG",
paragraphs: [ paragraphs: [
"Inhaber: Nick Rosenlöcher", "Inhaber: Nick Rosenlöcher",
"Rechtsform: Einzelunternehmen", "Rechtsform: Einzelunternehmen",
@@ -215,13 +241,13 @@ export const de: Dictionary = {
{ {
heading: "Redaktionell verantwortlich", heading: "Redaktionell verantwortlich",
paragraphs: [ paragraphs: [
"Verantwortlich für den redaktionellen Inhalt gemäß § 18 Abs. 2 MStV (u.a. die Beiträge im Bereich \"Honigwissen\"): Nick Rosenlöcher.", "Verantwortlich für den redaktionellen Inhalt gemäß § 18 Abs. 2 MStV (u.a. die Beiträge im Bereich \"Honigwissen\"): Nick Rosenlöcher, Anschrift wie oben.",
], ],
}, },
{ {
heading: "Haftung für Inhalte", heading: "Haftung für Inhalte",
paragraphs: [ paragraphs: [
"Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden entsprechender Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.", "Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden entsprechender Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.",
], ],
}, },
{ {
@@ -255,7 +281,7 @@ export const de: Dictionary = {
{ {
heading: "1. Verantwortlicher", heading: "1. Verantwortlicher",
paragraphs: [ paragraphs: [
"Nach unserer Einschätzung sind wir aufgrund der Größe und Art unseres Betriebs nicht gemäß § 38 BDSG zur Benennung einer/eines Datenschutzbeauftragten verpflichtet. Bei Fragen zum Datenschutz wenden Sie sich bitte direkt an die im Impressum genannte Kontaktadresse.", "Nach unserer Einschätzung sind wir aufgrund der Größe und Art unseres Betriebs nicht gemäß § 38 BDSG zur Benennung einer/eines Datenschutzbeauftragten verpflichtet. Bei Fragen zum Datenschutz wende dich bitte direkt an die oben genannte Kontaktadresse.",
], ],
}, },
{ {
@@ -267,7 +293,7 @@ export const de: Dictionary = {
{ {
heading: "3. Server-Logfiles beim Besuch der Website", heading: "3. Server-Logfiles beim Besuch der Website",
paragraphs: [ paragraphs: [
"Beim Aufruf unserer Website erhebt unser Hosting-Anbieter automatisch technische Zugriffsdaten (z.B. IP-Adresse, Datum und Uhrzeit des Zugriffs, aufgerufene Seite, Browsertyp, Referrer-URL) in sogenannten Server-Logfiles. Diese Verarbeitung ist zur Bereitstellung der Website technisch erforderlich (Art. 6 Abs. 1 lit. f DSGVO) und wird nach kurzer Zeit automatisch gelöscht.", "Beim Aufruf unserer Website erheben wir auf unserem Server automatisch technische Zugriffsdaten (z.B. IP-Adresse, Datum und Uhrzeit des Zugriffs, aufgerufene Seite, Browsertyp, Referrer-URL) in sogenannten Server-Logfiles. Diese Verarbeitung ist zur Bereitstellung der Website technisch erforderlich (Art. 6 Abs. 1 lit. f DSGVO) Die Logfiles werden nicht mit anderen Daten zusammengeführt und automatisch gelöscht, sobald sie für die Sicherheit und Fehleranalyse nicht mehr benötigt werden, spätestens nach 14 Tagen.",
], ],
}, },
{ {
@@ -276,14 +302,17 @@ export const de: Dictionary = {
"Für den Versand übermitteln wir deinen Namen und die Lieferanschrift an unseren Versanddienstleister DHL (DHL Paket GmbH, Bonn). Wählst du die Lieferung an eine Packstation oder Filiale, kommt die von dir gewählte Standortnummer hinzu. Suchst du im Bestellvorgang nach einer Packstation, wird dein eingegebener Ort beziehungsweise deine Postleitzahl zur Abfrage an DHL weitergegeben. Rufst du den Stand deiner Sendung unter „Bestellung verfolgen“ ab, fragen wir ihn mit Sendungsnummer und Postleitzahl der Lieferanschrift bei DHL ab und zeigen ihn dir direkt bei uns an. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.", "Für den Versand übermitteln wir deinen Namen und die Lieferanschrift an unseren Versanddienstleister DHL (DHL Paket GmbH, Bonn). Wählst du die Lieferung an eine Packstation oder Filiale, kommt die von dir gewählte Standortnummer hinzu. Suchst du im Bestellvorgang nach einer Packstation, wird dein eingegebener Ort beziehungsweise deine Postleitzahl zur Abfrage an DHL weitergegeben. Rufst du den Stand deiner Sendung unter „Bestellung verfolgen“ ab, fragen wir ihn mit Sendungsnummer und Postleitzahl der Lieferanschrift bei DHL ab und zeigen ihn dir direkt bei uns an. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Bei einer Bestellung erheben wir die für die Vertrags-, Zahlungs- und Versandabwicklung notwendigen Daten (Name, Adresse, E-Mail, ggf. Telefonnummer sowie die bestellten Artikel). Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).", "Bei einer Bestellung erheben wir die für die Vertrags-, Zahlungs- und Versandabwicklung notwendigen Daten (Name, Adresse, E-Mail, ggf. Telefonnummer sowie die bestellten Artikel). Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).",
"Zahlungen werden über unseren Zahlungsdienstleister Stripe Payments Europe, Ltd. abgewickelt. Dabei werden die für die Zahlungsabwicklung notwendigen Daten an Stripe übermittelt. Es gelten zusätzlich die Datenschutzhinweise von Stripe (stripe.com/de/privacy). Soweit dabei Daten an mit Stripe verbundene Unternehmen außerhalb der EU/des EWR (insbesondere Stripe, Inc. in den USA) übermittelt werden, erfolgt dies auf Grundlage von EU-Standardvertragsklauseln gemäß Art. 46 DSGVO.", "Zahlungen werden über unseren Zahlungsdienstleister Stripe Payments Europe, Ltd. abgewickelt. Dabei werden die für die Zahlungsabwicklung notwendigen Daten an Stripe übermittelt. Es gelten zusätzlich die Datenschutzhinweise von Stripe (stripe.com/de/privacy). Soweit dabei Daten an mit Stripe verbundene Unternehmen außerhalb der EU/des EWR (insbesondere Stripe, Inc. in den USA) übermittelt werden, erfolgt dies auf Grundlage von EU-Standardvertragsklauseln gemäß Art. 46 DSGVO.",
"Wählst du im Bezahlschritt PayPal oder Klarna, wirst du zum jeweiligen Anbieter weitergeleitet, und die für die Zahlung erforderlichen Bestelldaten werden an ihn übermittelt: PayPal (Europe) S.à r.l. et Cie, S.C.A., 22–24 Boulevard Royal, L-2449 Luxemburg, bzw. Klarna Bank AB (publ), Sveavägen 46, 111 34 Stockholm, Schweden. Diese Anbieter können eigene Prüfungen durchführen, etwa zur Identität oder Zahlungsfähigkeit; dafür sind sie selbst verantwortlich, und es gelten ihre Datenschutzhinweise. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Bestell- und Rechnungsdaten werden aufgrund handels- und steuerrechtlicher Aufbewahrungspflichten (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer gespeichert.", "Bestell- und Rechnungsdaten werden aufgrund handels- und steuerrechtlicher Aufbewahrungspflichten (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer gespeichert.",
"Bestellbestätigungen und andere E-Mails zu deinem Vertrag oder Kundenkonto versenden wir über Microsoft 365 (Microsoft Ireland Operations Limited, One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Irland). Microsoft verarbeitet die Daten als unser Auftragsverarbeiter auf Grundlage eines Vertrags nach Art. 28 DSGVO. Eine Übermittlung an die Microsoft Corporation in die USA kann stattfinden; Microsoft ist unter dem EU-US Data Privacy Framework zertifiziert. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Widerrufst du einen Vertrag über unsere Online-Widerrufsfunktion, speichern wir Name, Bestellnummer, E-Mail-Adresse, deine freiwillige Nachricht und den Zeitpunkt des Eingangs, senden dir eine Eingangsbestätigung und ordnen den Widerruf deiner Bestellung zu. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB. Die Erklärung bewahren wir als Nachweis auf, bis die Verjährungsfristen für Ansprüche aus dem Vertrag abgelaufen sind.",
], ],
}, },
{ {
heading: "5. Kundenkonto", heading: "5. Kundenkonto",
paragraphs: [ paragraphs: [
"Alternativ kannst du dich mit einem Microsoft-Konto anmelden (Microsoft Entra ID). Dabei wirst du zu Microsoft weitergeleitet und meldest dich dort an; wir selbst erhalten kein Passwort, sondern nur deine E-Mail-Adresse und deinen Namen, um dein Kundenkonto zuzuordnen. Welche Daten Microsoft bei der Anmeldung selbst verarbeitet, richtet sich nach den Datenschutzhinweisen von Microsoft. Anbieter ist die Microsoft Ireland Operations Limited; eine Übermittlung an die Microsoft Corporation in die USA kann stattfinden. Microsoft ist unter dem EU-US Data Privacy Framework zertifiziert. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Legst du ein Kundenkonto an, speichern wir die von dir angegebenen Daten (Name, E-Mail, Passwort verschlüsselt als Hash) sowie optional hinterlegte Lieferadressen, um dir Bestellhistorie, Rechnungseinsicht und eine schnellere Kasse zu ermöglichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Du kannst dein Konto und die zugehörigen Daten jederzeit über eine formlose Anfrage an uns löschen lassen.", "Legst du ein Kundenkonto an, speichern wir die von dir angegebenen Daten (Name, E-Mail, Passwort verschlüsselt als Hash) sowie optional hinterlegte Lieferadressen, um dir Bestellhistorie, Rechnungseinsicht und eine schnellere Kasse zu ermöglichen. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Du kannst dein Konto und die zugehörigen Daten jederzeit über eine formlose Anfrage an uns löschen lassen.",
"Alternativ kannst du dich mit einem Microsoft-Konto anmelden (Microsoft Entra ID). Dabei wirst du zu Microsoft weitergeleitet und meldest dich dort an; wir selbst erhalten kein Passwort, sondern nur deine E-Mail-Adresse und deinen Namen, um dein Kundenkonto zuzuordnen. Welche Daten Microsoft bei der Anmeldung selbst verarbeitet, richtet sich nach den Datenschutzhinweisen von Microsoft. Anbieter ist die Microsoft Ireland Operations Limited; eine Übermittlung an die Microsoft Corporation in die USA kann stattfinden. Microsoft ist unter dem EU-US Data Privacy Framework zertifiziert. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Zur Anmeldung wird ein technisch notwendiges Cookie gesetzt, siehe unsere Cookie-Richtlinie.", "Zur Anmeldung wird ein technisch notwendiges Cookie gesetzt, siehe unsere Cookie-Richtlinie.",
], ],
}, },
@@ -291,6 +320,7 @@ export const de: Dictionary = {
heading: "6. Newsletter (Erntepost)", heading: "6. Newsletter (Erntepost)",
paragraphs: [ paragraphs: [
"Meldest du dich zu unserer Erntepost an, verwenden wir deine E-Mail-Adresse ausschließlich zum Versand von Informationen über neue Ernten. Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Eine Abmeldung ist jederzeit über den Link in jeder E-Mail oder im Kundenkonto möglich.", "Meldest du dich zu unserer Erntepost an, verwenden wir deine E-Mail-Adresse ausschließlich zum Versand von Informationen über neue Ernten. Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Eine Abmeldung ist jederzeit über den Link in jeder E-Mail oder im Kundenkonto möglich.",
"Die Anmeldung erfolgt im Double-Opt-in-Verfahren: Eingetragen wirst du erst, nachdem du den Link in unserer Bestätigungs-E-Mail angeklickt hast; den Zeitpunkt dieser Bestätigung speichern wir als Nachweis deiner Einwilligung. Meldest du dich in deinem Kundenkonto an, gilt der Klick dort als Einwilligung, und wir speichern ebenfalls dessen Zeitpunkt. Mit der Abmeldung widerrufst du deine Einwilligung mit Wirkung für die Zukunft (Art. 7 Abs. 3 DSGVO). Deine Adresse bewahren wir danach mit dem Abmeldezeitpunkt auf, um die Einwilligung und ihren Widerruf nachweisen zu können und dir keine weitere Erntepost zu schicken (Art. 6 Abs. 1 lit. f DSGVO).",
], ],
}, },
{ {
@@ -315,14 +345,36 @@ export const de: Dictionary = {
], ],
}, },
{ {
heading: "10. Cookies", heading: "10. Produktbewertungen",
paragraphs: [
"Schreibst du als angemeldete Kundin oder angemeldeter Kunde eine Bewertung, speichern wir Sternebewertung, Text, Zeitpunkt und die Zuordnung zu deinem Kundenkonto. Nach unserer Freigabe erscheint die Bewertung auf der Produktseite zusammen mit deinem Vornamen und dem Datum. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO; die Bewertung bleibt gespeichert, bis du ihre Löschung verlangst oder dein Kundenkonto gelöscht wird.",
],
},
{
heading: "11. Benachrichtigung bei Verfügbarkeit",
paragraphs: [
"Lässt du dich über die Verfügbarkeit eines ausverkauften Produkts informieren, speichern wir deine E-Mail-Adresse, das Produkt und deine Sprache, um dir genau eine Benachrichtigung zu schicken, sobald es wieder erhältlich ist. Rechtsgrundlage ist deine Anfrage (Art. 6 Abs. 1 lit. b DSGVO). Du kannst die Anmeldung jederzeit formlos bei uns löschen lassen.",
],
},
{
heading: "12. Cookies",
paragraphs: ["Details zu den auf dieser Website eingesetzten Cookies findest du in unserer Cookie-Richtlinie."], paragraphs: ["Details zu den auf dieser Website eingesetzten Cookies findest du in unserer Cookie-Richtlinie."],
}, },
{ {
heading: "11. Deine Rechte", heading: "13. Speicherdauer und Pflicht zur Bereitstellung",
paragraphs: [
"Wir speichern personenbezogene Daten nur so lange, wie es für den jeweiligen Zweck erforderlich ist oder gesetzliche Aufbewahrungsfristen es verlangen (bei Bestell- und Rechnungsdaten bis zu zehn Jahre nach § 147 AO, § 257 HGB). Danach werden sie gelöscht.",
"Die Angaben, die für eine Bestellung erforderlich sind, brauchen wir, um den Vertrag zu schließen und zu erfüllen; ohne sie ist keine Bestellung möglich. Alle weiteren Angaben (z. B. Newsletter, Bewertungen) sind freiwillig.",
"Eine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.",
],
},
{
heading: "14. Deine Rechte",
paragraphs: [ paragraphs: [
"Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Datenübertragbarkeit (Art. 20 DSGVO) sowie Widerspruch gegen die Verarbeitung (Art. 21 DSGVO) deiner personenbezogenen Daten.", "Du hast das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Datenübertragbarkeit (Art. 20 DSGVO) sowie Widerspruch gegen die Verarbeitung (Art. 21 DSGVO) deiner personenbezogenen Daten.",
"Zudem steht dir ein Beschwerderecht bei einer Datenschutz-Aufsichtsbehörde zu, z.B. dem Bayerischen Landesamt für Datenschutzaufsicht.", "Zudem steht dir ein Beschwerderecht bei einer Datenschutz-Aufsichtsbehörde zu, z.B. dem Bayerischen Landesamt für Datenschutzaufsicht (BayLDA), Promenade 18, 91522 Ansbach, www.lda.bayern.de.",
"Hast du eine Einwilligung erteilt, kannst du sie jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO). Die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung bleibt davon unberührt.",
"Widerspruchsrecht: Verarbeiten wir Daten auf Grundlage berechtigter Interessen (Art. 6 Abs. 1 lit. f DSGVO), kannst du aus Gründen, die sich aus deiner besonderen Situation ergeben, jederzeit widersprechen (Art. 21 Abs. 1 DSGVO). Wir verarbeiten die Daten dann nicht mehr, es sei denn, wir können zwingende schutzwürdige Gründe nachweisen. Einer Verarbeitung zu Zwecken der Direktwerbung kannst du jederzeit ohne Angabe von Gründen widersprechen (Art. 21 Abs. 2 DSGVO). Eine formlose Nachricht an uns genügt.",
], ],
}, },
], ],
@@ -340,6 +392,7 @@ export const de: Dictionary = {
heading: "2. Vertragsschluss", heading: "2. Vertragsschluss",
paragraphs: [ paragraphs: [
"Die Darstellung der Produkte im Shop stellt kein bindendes Angebot dar, sondern eine unverbindliche Aufforderung, eine Bestellung aufzugeben. Mit Abschluss der Bestellung gibst du ein verbindliches Angebot zum Kauf ab, das wir durch eine Bestätigung per E-Mail oder durch Lieferung der Ware annehmen.", "Die Darstellung der Produkte im Shop stellt kein bindendes Angebot dar, sondern eine unverbindliche Aufforderung, eine Bestellung aufzugeben. Mit Abschluss der Bestellung gibst du ein verbindliches Angebot zum Kauf ab, das wir durch eine Bestätigung per E-Mail oder durch Lieferung der Ware annehmen.",
"Bis zum Abschluss der Zahlung kannst du deine Eingaben im Bestellformular jederzeit korrigieren; aus dem Zahlungsschritt führt „Zurück zu deinen Angaben“ wieder zum Formular. Der Vertrag wird in deutscher Sprache geschlossen. Wir speichern den Vertragstext; die Bestelldaten erhältst du mit der Bestätigung per E-Mail, und mit Kundenkonto kannst du deine Bestellungen dort jederzeit einsehen. Diese AGB kannst du auf dieser Seite jederzeit abrufen, ausdrucken und speichern.",
], ],
}, },
{ {
@@ -349,18 +402,18 @@ export const de: Dictionary = {
{ {
heading: "4. Zahlungsarten", heading: "4. Zahlungsarten",
paragraphs: [ paragraphs: [
"Wir bieten PayPal, Kreditkarte, Apple Pay und Google Pay über unseren Zahlungsdienstleister Stripe an. Eine Zahlung per Überweisung ist auf Anfrage möglich. Details siehe unsere Seite \"Versand und Zahlung\" sowie unsere Datenschutzerklärung zu Stripe.", "Die Zahlung erfolgt über unseren Zahlungsdienstleister Stripe. Welche Zahlungsarten (z. B. Kreditkarte, SEPA-Lastschrift, Klarna, PayPal, Apple Pay, Google Pay) zur Verfügung stehen, zeigen wir im Bestellvorgang und auf unserer Seite \"Versand und Zahlung\" an. Eine Zahlung per Überweisung ist auf Anfrage möglich.",
"Die Kaufpreiszahlung ist grundsätzlich sofort mit Bestellung fällig (Vorkasse über den gewählten Zahlungsdienst), sofern nichts anderes vereinbart wurde.", "Die Kaufpreiszahlung ist grundsätzlich sofort mit Bestellung fällig (Vorkasse über den gewählten Zahlungsdienst), sofern nichts anderes vereinbart wurde.",
], ],
}, },
{ {
heading: "5. Lieferung und Abholung", heading: "5. Lieferung und Abholung",
paragraphs: ["Wir liefern innerhalb Deutschlands. Alternativ kann die Ware nach Absprache direkt bei uns in Altenmünster abgeholt werden."], paragraphs: ["Wir liefern in das auf unserer Seite \"Versand und Zahlung\" genannte Liefergebiet. Alternativ kann die Ware nach Absprache direkt bei uns in Altenmünster abgeholt werden."],
}, },
{ {
heading: "6. Widerrufsrecht", heading: "6. Widerrufsrecht",
paragraphs: [ paragraphs: [
"Verbraucher:innen steht grundsätzlich ein gesetzliches Widerrufsrecht zu. Einzelheiten, insbesondere zu Ausnahmen bei schnell verderblichen Waren wie Honig, entnimmst du unserer separaten Widerrufsbelehrung.", "Verbraucher:innen steht grundsätzlich ein gesetzliches Widerrufsrecht zu. Einzelheiten, insbesondere zu den gesetzlichen Ausnahmen, entnimmst du unserer separaten Widerrufsbelehrung.",
], ],
}, },
{ {
@@ -396,16 +449,28 @@ export const de: Dictionary = {
title: "Widerrufsbelehrung", title: "Widerrufsbelehrung",
sections: [ sections: [
{ {
// Gesamte Belehrung = amtliches Muster (Anlage 1 zu Art. 246a § 1
// Abs. 2 Satz 2 EGBGB), nur in der Du-Form. Nur das vollstaendige
// Muster loest die Gesetzlichkeitsfiktion aus; fehlt z. B. die
// Anschrift, verlaengert sich die Widerrufsfrist auf bis zu zwoelf
// Monate und 14 Tage (§ 356 Abs. 3 BGB). {{anbieter}} ersetzt
// app/widerrufsbelehrung/page.tsx durch Name, Anschrift und E-Mail aus
// den Firmendaten.
heading: "Widerrufsrecht", heading: "Widerrufsrecht",
paragraphs: [ paragraphs: [
"Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem du oder ein von dir benannter Dritter die Waren in Besitz genommen hat.", "Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.",
"Um dein Widerrufsrecht auszuüben, musst du uns mittels einer eindeutigen Erklärung (z.B. per E-Mail) über deinen Entschluss, diesen Vertrag zu widerrufen, informieren.", "Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem du oder ein von dir benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen hast bzw. hat.",
"Um dein Widerrufsrecht auszuüben, musst du uns ({{anbieter}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über deinen Entschluss, diesen Vertrag zu widerrufen, informieren. Du kannst dafür das unten stehende Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.",
"Du kannst dein Widerrufsrecht auch online unter {{widerrufsfunktion}} ausüben. Wenn du diese Online-Funktion nutzt, übermitteln wir dir auf einem dauerhaften Datenträger (z. B. per E-Mail) unverzüglich eine Eingangsbestätigung.",
"Zur Wahrung der Widerrufsfrist reicht es aus, dass du die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absendest.",
], ],
}, },
{ {
heading: "Folgen des Widerrufs", heading: "Folgen des Widerrufs",
paragraphs: [ paragraphs: [
"Wenn du diesen Vertrag widerrufst, erstatten wir dir alle Zahlungen, die wir von dir erhalten haben, unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurück, an dem die Mitteilung über deinen Widerruf bei uns eingegangen ist.", "Wenn du diesen Vertrag widerrufst, haben wir dir alle Zahlungen, die wir von dir erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass du eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt hast), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über deinen Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das du bei der ursprünglichen Transaktion eingesetzt hast, es sei denn, mit dir wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden dir wegen dieser Rückzahlung Entgelte berechnet.",
"Wir können die Rückzahlung verweigern, bis wir die Waren wieder zurückerhalten haben oder bis du den Nachweis erbracht hast, dass du die Waren zurückgesandt hast, je nachdem, welches der frühere Zeitpunkt ist.",
"Du hast die Waren unverzüglich und in jedem Fall spätestens binnen vierzehn Tagen ab dem Tag, an dem du uns über den Widerruf dieses Vertrags unterrichtest, an uns zurückzusenden oder zu übergeben. Die Frist ist gewahrt, wenn du die Waren vor Ablauf der Frist von vierzehn Tagen absendest.",
// Wortlaut aus der amtlichen Muster-Widerrufsbelehrung (Anlage 1 zu // Wortlaut aus der amtlichen Muster-Widerrufsbelehrung (Anlage 1 zu
// Art. 246a § 1 Abs. 2 EGBGB), bewusst unveraendert uebernommen. // Art. 246a § 1 Abs. 2 EGBGB), bewusst unveraendert uebernommen.
// Ohne diese Information traegt nach § 357 Abs. 6 BGB der Verkaeufer // Ohne diese Information traegt nach § 357 Abs. 6 BGB der Verkaeufer
@@ -416,12 +481,22 @@ export const de: Dictionary = {
// Abweichung vom Muster kostet dessen Gesetzlichkeitsfiktion. Sie // Abweichung vom Muster kostet dessen Gesetzlichkeitsfiktion. Sie
// steht stattdessen unter "Versand und Zahlung" → "Rücksendung". // steht stattdessen unter "Versand und Zahlung" → "Rücksendung".
"Du trägst die unmittelbaren Kosten der Rücksendung der Waren.", "Du trägst die unmittelbaren Kosten der Rücksendung der Waren.",
"Du musst für einen etwaigen Wertverlust der Waren nur aufkommen, wenn dieser Wertverlust auf einen zur Prüfung der Beschaffenheit, Eigenschaften und Funktionsweise der Waren nicht notwendigen Umgang mit ihnen zurückzuführen ist.",
], ],
}, },
{ {
heading: "Ausschluss des Widerrufsrechts", heading: "Ausschluss des Widerrufsrechts",
paragraphs: [ paragraphs: [
"Das Widerrufsrecht besteht nicht bei Lieferung von Waren, die schnell verderben können oder deren Verfallsdatum schnell überschritten würde, sowie bei versiegelten Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde.", "Das Widerrufsrecht besteht nicht bei Verträgen zur Lieferung versiegelter Waren, die aus Gründen des Gesundheitsschutzes oder der Hygiene nicht zur Rückgabe geeignet sind, wenn ihre Versiegelung nach der Lieferung entfernt wurde (§ 312g Abs. 2 Nr. 3 BGB). Das betrifft insbesondere geöffnete Honiggläser, deren Sicherheitsverschluss aufgebrochen wurde.",
],
},
{
heading: "Muster-Widerrufsformular",
paragraphs: [
"(Wenn du den Vertrag widerrufen willst, dann fülle bitte dieses Formular aus und sende es zurück.)",
"An: {{anbieter}}",
"- Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)\n- Bestellt am (*)/erhalten am (*)\n- Name des/der Verbraucher(s)\n- Anschrift des/der Verbraucher(s)\n- Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)\n- Datum",
"(*) Unzutreffendes streichen.",
], ],
}, },
], ],
@@ -468,13 +543,13 @@ export const de: Dictionary = {
heading: "Rechtsgrundlagen", heading: "Rechtsgrundlagen",
paragraphs: [ paragraphs: [
"Speichern wir Informationen auf deinem Endgerät oder lesen sie aus, ist dafür grundsätzlich deine Einwilligung erforderlich (§ 25 Abs. 1 TDDDG). Keine Einwilligung braucht es, wenn die Speicherung unbedingt erforderlich ist, damit wir einen von dir ausdrücklich gewünschten Dienst bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG), etwa die Anmeldung im Kundenkonto oder den Bestellabschluss.", "Speichern wir Informationen auf deinem Endgerät oder lesen sie aus, ist dafür grundsätzlich deine Einwilligung erforderlich (§ 25 Abs. 1 TDDDG). Keine Einwilligung braucht es, wenn die Speicherung unbedingt erforderlich ist, damit wir einen von dir ausdrücklich gewünschten Dienst bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG), etwa die Anmeldung im Kundenkonto oder den Bestellabschluss.",
"Soweit dabei personenbezogene Daten verarbeitet werden, stützen wir das auf Art. 6 Abs. 1 lit. b DSGVO (Vertrag und vorvertragliche Maßnahmen, z. B. Kundenkonto und Bestellung) oder Art. 6 Abs. 1 lit. f DSGVO (unser berechtigtes Interesse an einer sicheren, funktionsfähigen Website). Setzen wir künftig einwilligungspflichtige Technologien ein, ist Rechtsgrundlage deine Einwilligung nach § 25 Abs. 1 TDDDG in Verbindung mit Art. 6 Abs. 1 lit. a DSGVO.", "Soweit dabei personenbezogene Daten verarbeitet werden, stützen wir das auf Art. 6 Abs. 1 lit. b DSGVO (Vertrag und vorvertragliche Maßnahmen, z. B. Kundenkonto und Bestellung) oder Art. 6 Abs. 1 lit. f DSGVO (unser berechtigtes Interesse an einer sicheren, funktionsfähigen Website). Für einwilligungspflichtige Speicherungen (siehe „Cookies mit Einwilligung“) ist Rechtsgrundlage deine Einwilligung nach § 25 Abs. 1 TDDDG in Verbindung mit Art. 6 Abs. 1 lit. a DSGVO.",
], ],
}, },
{ {
heading: "Überblick", heading: "Überblick",
paragraphs: [ paragraphs: [
"Wir verwenden ausschließlich technisch notwendige Cookies und Speichereinträge. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.", "Wir verwenden technisch notwendige Cookies und Speichereinträge und — nur mit deiner Einwilligung — einen einzigen weiteren Speichereintrag für die Liste zuletzt angesehener Produkte. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.",
"Die meisten der unten genannten Cookies entstehen erst, wenn du eine bestimmte Funktion nutzt (z. B. Anmeldung, Bezahlung, Sprachwechsel). Beim bloßen Aufrufen der Website wird kein Cookie gesetzt.", "Die meisten der unten genannten Cookies entstehen erst, wenn du eine bestimmte Funktion nutzt (z. B. Anmeldung, Bezahlung, Sprachwechsel). Beim bloßen Aufrufen der Website wird kein Cookie gesetzt.",
], ],
}, },
@@ -503,25 +578,25 @@ export const de: Dictionary = {
heading: "Lokaler Speicher (localStorage)", heading: "Lokaler Speicher (localStorage)",
paragraphs: [ paragraphs: [
"Die folgenden Einträge liegen im lokalen Speicher deines Browsers. Anders als Cookies werden sie nicht bei jedem Seitenaufruf automatisch an uns gesendet und laufen nicht von selbst ab: Sie bleiben, bis du sie löschst (über die Browser-Einstellungen „Websitedaten löschen“) oder die Website sie entfernt. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO.", "Die folgenden Einträge liegen im lokalen Speicher deines Browsers. Anders als Cookies werden sie nicht bei jedem Seitenaufruf automatisch an uns gesendet und laufen nicht von selbst ab: Sie bleiben, bis du sie löschst (über die Browser-Einstellungen „Websitedaten löschen“) oder die Website sie entfernt. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO.",
"wabenhain-cart – Dein Warenkorb (Artikel, Mengen, Preise). An uns übermittelt wird er erst, wenn du eine Bestellung abschickst. Nach erfolgreicher Bestellung wird er geleert.", "wabenhain-cart – Dein Warenkorb (Artikel, Mengen, Preise). An uns übermittelt wird er erst, wenn du eine Bestellung abschickst. Nach erfolgreicher Bestellung wird er geleert. Speicherdauer: bis zur nächsten Bestellung oder bis du ihn selbst leerst.",
"wabenhain-recently-viewed – Die Kennungen der bis zu 6 zuletzt angesehenen Produkte, um sie dir auf Produktseiten erneut anzuzeigen. Zum Anzeigen werden nur diese Produktkennungen abgefragt; wir speichern keinen Verlauf auf unseren Servern.", "wabenhain-cookie-consent – Deine Auswahl im Cookie-Banner („Nur notwendige“ oder „Alle akzeptieren“), damit der Banner nicht bei jedem Besuch erneut erscheint und wir deine Entscheidung nachweisen können. Speicherdauer: bis du deine Auswahl änderst oder die Websitedaten löschst. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit Art. 7 Abs. 1 DSGVO.",
"wabenhain-cookie-consent – Deine Auswahl im Cookie-Banner („Nur notwendige“ oder „Alle akzeptieren“), damit der Banner nicht bei jedem Besuch erneut erscheint und wir deine Entscheidung nachweisen können. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit Art. 7 Abs. 1 DSGVO.",
], ],
}, },
{ {
heading: "Cookies von Drittanbietern", heading: "Cookies von Drittanbietern",
paragraphs: [ paragraphs: [
"Stripe (Zahlungsabwicklung): Erst im Zahlungsschritt des Bestellabschlusses lädt dein Browser das Zahlungsformular von Stripe. Stripe setzt dabei zur Betrugsprävention und für die sichere Zahlungsabwicklung eigene Cookies, insbesondere __stripe_mid (Speicherdauer 1 Jahr) und __stripe_sid (Speicherdauer 30 Minuten) sowie weitere Cookies innerhalb des Zahlungsformulars auf Domains von Stripe. Name und Laufzeit dieser Cookies legt Stripe fest. Anbieter: Stripe Payments Europe, Ltd., 1 Grand Canal Street Lower, Dublin 2, Irland. Eine Übermittlung an Stripe, Inc. in die USA kann stattfinden; sie erfolgt auf Grundlage von EU-Standardvertragsklauseln (Art. 46 DSGVO). Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b DSGVO. Weitere Informationen: stripe.com/de/privacy und stripe.com/de/legal/cookies-policy.", "Stripe (Zahlungsabwicklung): Erst im Zahlungsschritt des Bestellabschlusses lädt dein Browser das Zahlungsformular von Stripe. Stripe setzt dabei zur Betrugsprävention und für die sichere Zahlungsabwicklung eigene Cookies, insbesondere __stripe_mid (Speicherdauer 1 Jahr) und __stripe_sid (Speicherdauer 30 Minuten) sowie weitere Cookies innerhalb des Zahlungsformulars auf Domains von Stripe. Name und Laufzeit dieser Cookies legt Stripe fest. Anbieter: Stripe Payments Europe, Ltd., 1 Grand Canal Street Lower, Dublin 2, Irland. Eine Übermittlung an Stripe, Inc. in die USA kann stattfinden; sie erfolgt auf Grundlage von EU-Standardvertragsklauseln (Art. 46 DSGVO). Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b DSGVO. Weitere Informationen: stripe.com/de/privacy und stripe.com/de/legal/cookies-policy.",
"Microsoft (Anmeldung mit Microsoft-Konto): Wählst du „Mit Microsoft anmelden“, wirst du auf eine Anmeldeseite von Microsoft weitergeleitet. Die dort gesetzten Cookies verantwortet Microsoft (Microsoft Ireland Operations Limited); es gelten die Datenschutzhinweise von Microsoft. Auf unserer Website setzt Microsoft keine Cookies.", "Microsoft (Anmeldung mit Microsoft-Konto): Wählst du „Mit Microsoft anmelden“, wirst du auf eine Anmeldeseite von Microsoft weitergeleitet. Die dort gesetzten Cookies verantwortet Microsoft; es gelten die Datenschutzhinweise von Microsoft. Auf unserer Website setzt Microsoft keine Cookies. Anbieter: Microsoft Ireland Operations Limited, One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Irland.",
"Cloudflare Turnstile (Schutz der Formulare): Auf Seiten mit Formularen (Anmeldung, Registrierung, Passwort-Reset, Zwei-Faktor-Bestätigung, Bestellabschluss, Newsletter, Firmenanfrage, Verfügbarkeitsbenachrichtigung) lädt dein Browser ein Skript von challenges.cloudflare.com. Es unterscheidet Eingaben von Menschen und automatisierten Programmen. Dabei werden deine IP-Adresse sowie technische Merkmale deines Browsers an Cloudflare, Inc. (USA) übermittelt. Nach Angaben des Anbieters setzt Turnstile keine Cookies zu Werbezwecken und erstellt kein seitenübergreifendes Nutzerprofil. Cloudflare ist unter dem EU-US Data Privacy Framework zertifiziert. Einzelheiten in unserer Datenschutzerklärung.", "Cloudflare Turnstile (Schutz der Formulare): Auf Seiten mit Formularen (Anmeldung, Registrierung, Passwort-Reset, Zwei-Faktor-Bestätigung, Bestellabschluss, Newsletter, Firmenanfrage, Verfügbarkeitsbenachrichtigung) lädt dein Browser ein Skript von challenges.cloudflare.com. Es unterscheidet Eingaben von Menschen und automatisierten Programmen. Dabei werden deine IP-Adresse sowie technische Merkmale deines Browsers an Cloudflare, Inc. (USA) übermittelt. Nach Angaben des Anbieters setzt Turnstile keine Cookies zu Werbezwecken und erstellt kein seitenübergreifendes Nutzerprofil. Cloudflare ist unter dem EU-US Data Privacy Framework zertifiziert. Anbieter: Cloudflare, Inc., 101 Townsend Street, San Francisco, CA 94107, USA. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. f DSGVO. Weitere Informationen: siehe Abschnitt „Cloudflare Turnstile“ in unserer Datenschutzerklärung.",
"Google-Rezensionen: Die Bewertungen auf unserer Startseite rufen wir serverseitig über die Google Places API ab. Dein Browser baut dabei keine Verbindung zu Google auf, und es wird kein Cookie von Google gesetzt.", "Google-Rezensionen: Die Bewertungen auf unserer Startseite rufen wir serverseitig über die Google Places API ab. Dein Browser baut dabei keine Verbindung zu Google auf, und es wird kein Cookie von Google gesetzt.",
], ],
}, },
{ {
heading: "Cookies mit Einwilligung", heading: "Cookies mit Einwilligung",
paragraphs: [ paragraphs: [
"Derzeit setzen wir keine Cookies oder Technologien ein, die deine Einwilligung erfordern. Die Auswahl „Alle akzeptieren“ im Cookie-Banner schaltet deshalb aktuell nichts Zusätzliches frei; mit „Nur notwendige“ nutzt du die Website ohne jede Einschränkung.", "Nur wenn du im Cookie-Banner „Alle akzeptieren“ wählst, speichern wir den folgenden Eintrag. Ohne Einwilligung wird er nicht angelegt; die Liste gilt dann nur, solange der Tab geöffnet ist. Wählst du „Nur notwendige“ oder widerrufst du deine Einwilligung, löschen wir den Eintrag sofort. Rechtsgrundlage: § 25 Abs. 1 TDDDG in Verbindung mit Art. 6 Abs. 1 lit. a DSGVO.",
"Sollten wir künftig z. B. eine Reichweitenmessung einsetzen, nennen wir sie vorher an dieser Stelle mit Anbieter, Zweck und Speicherdauer und aktivieren sie erst nach deiner ausdrücklichen, freiwilligen Einwilligung über den Cookie-Banner. Eine Ablehnung hat keine Nachteile für dich.", "wabenhain-recently-viewed – Die Kennungen der bis zu 6 zuletzt angesehenen Produkte (localStorage), um sie dir auf Produktseiten erneut anzuzeigen. Zum Anzeigen werden nur diese Produktkennungen abgefragt; wir speichern keinen Verlauf auf unseren Servern. Speicherdauer: bis du deine Einwilligung widerrufst oder die Websitedaten in deinem Browser löschst.",
"Weitere einwilligungspflichtige Technologien, etwa eine Reichweitenmessung, setzen wir nicht ein. Sollte sich das ändern, nennen wir sie vorher an dieser Stelle mit Anbieter, Zweck und Speicherdauer und aktivieren sie erst nach deiner ausdrücklichen, freiwilligen Einwilligung über den Cookie-Banner. Eine Ablehnung hat keine Nachteile für dich.",
], ],
}, },
{ {
@@ -1182,6 +1257,37 @@ export const de: Dictionary = {
sectionVisibility: "Sichtbarkeit", sectionVisibility: "Sichtbarkeit",
create: "Anlegen", create: "Anlegen",
}, },
adminEmailTestPage: {
title: "E-Mail-Versand testen",
intro: "Holt ein Token über die App-Registrierung von „Mit Microsoft anmelden“, meldet sich bei Microsoft 365 als SMTP_USER an und schickt eine Test-Mail an deine eigene Adresse. Schlägt etwas fehl, siehst du die Meldung des Mailservers und was zu tun ist.",
button: "Test-Mail senden",
running: "Wird getestet …",
success: (to) => `Versand funktioniert. Die Test-Mail ist an ${to} unterwegs.`,
notConfigured: "Der Mailversand ist nicht vollständig eingerichtet. Ohne die unten genannten Werte (Kubernetes-ConfigMap bzw. -Secret) werden E-Mails nur ins Server-Log geschrieben.",
failureHeading: "Versand fehlgeschlagen",
causeLabel: "Was zu tun ist",
detailLabel: "Meldung des Mailservers",
configLabel: "Verwendete Einstellungen",
authModeOAuth: "Microsoft 365, OAuth 2.0 über die SSO-App",
missingLabel: "Fehlende Einstellungen",
},
adminWithdrawalsPage: {
title: "Widerrufe",
intro: "Erklärungen über die Online-Widerrufsfunktion (§ 356a BGB). Die Erstattung ist spätestens 14 Tage nach Eingang fällig; du darfst sie zurückhalten, bis die Ware zurück ist oder die Rücksendung nachgewiesen wurde.",
backToDashboard: "Zurück zum Dashboard",
empty: "Noch keine Widerrufe eingegangen.",
statusOpen: "Offen",
statusProcessed: "Erledigt",
matched: "Bestellung zugeordnet",
unmatched: "Keine passende Bestellung — bitte prüfen",
mailSent: "Eingangsbestätigung versendet",
mailNotSent: "Eingangsbestätigung NICHT versendet — bitte manuell nachholen",
receivedAt: "Eingegangen",
processedOn: (date) => `Erledigt am ${date}`,
markProcessed: "Als erledigt markieren",
reopen: "Wieder öffnen",
viewOrder: "Bestellung ansehen",
},
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Bestellung ${orderNumber} jetzt mit ${amount} per Stripe erstatten? Das lässt sich nicht rückgängig machen.`, refundConfirm: (orderNumber, amount) => `Bestellung ${orderNumber} jetzt mit ${amount} per Stripe erstatten? Das lässt sich nicht rückgängig machen.`,
rejectConfirm: (orderNumber) => `Retoure zu Bestellung ${orderNumber} wirklich ablehnen?`, rejectConfirm: (orderNumber) => `Retoure zu Bestellung ${orderNumber} wirklich ablehnen?`,
@@ -1394,6 +1500,7 @@ export const de: Dictionary = {
navDashboard: "Dashboard", navDashboard: "Dashboard",
navOrders: "Bestellungen", navOrders: "Bestellungen",
navReturns: "Retouren", navReturns: "Retouren",
navWithdrawals: "Widerrufe",
navProducts: "Produkte", navProducts: "Produkte",
navHarvests: "Ernten", navHarvests: "Ernten",
navBatches: "Chargen", navBatches: "Chargen",
@@ -1403,6 +1510,7 @@ export const de: Dictionary = {
navVouchers: "Gutscheine", navVouchers: "Gutscheine",
navReviews: "Bewertungen", navReviews: "Bewertungen",
navAuditLog: "Audit-Log", navAuditLog: "Audit-Log",
navEmailTest: "E-Mail-Test",
navContent: "Inhalte", navContent: "Inhalte",
navAriaLabel: "Admin-Navigation", navAriaLabel: "Admin-Navigation",
mobileNavAriaLabel: "Mobile Admin-Navigation", mobileNavAriaLabel: "Mobile Admin-Navigation",
+129 -28
View File
@@ -170,6 +170,32 @@ export const en: Dictionary = {
}, },
legalPage: { legalPage: {
reviewNotice: "", reviewNotice: "",
tocLabel: "Contents",
revisionLabel: "Last updated",
},
withdrawalPage: {
title: "Withdraw from contract",
intro: "Here you can withdraw from your purchase contract online within the 14-day withdrawal period. You do not need a customer account and do not have to give a reason.",
nameLabel: "Your name",
orderNumberLabel: "Order number",
orderNumberHint: "Shown in your order confirmation, e.g. WH-2026-0042.",
emailLabel: "Email address",
emailHint: "We will send the confirmation of receipt to this address.",
messageLabel: "Message (optional)",
requiredNote: "Only name, order number and email address are required.",
startButton: "Withdraw from contract",
reviewHeading: "Please check your withdrawal",
reviewIntro: "By clicking “Confirm withdrawal” we will submit this notice:",
confirmButton: "Confirm withdrawal",
editButton: "Edit details",
submitting: "Sending …",
successHeading: "Your withdrawal has been received",
successMailSent: "We have just emailed you a confirmation of receipt containing your notice and the date and time it was received.",
successMailFailed: "Your withdrawal has been validly received. The confirmation email could not be sent just now; we will send it to you as soon as possible. Please save this page as proof.",
receivedAtLabel: "Received on",
errorMessage: "Your withdrawal could not be sent. Please try again or email us — a withdrawal by email or letter is also valid.",
policyLink: "Right of withdrawal",
footerButton: "Withdraw from contract",
}, },
notFoundPage: { notFoundPage: {
title: "This page doesn't exist, at least not anymore.", title: "This page doesn't exist, at least not anymore.",
@@ -186,7 +212,7 @@ export const en: Dictionary = {
cookieConsent: { cookieConsent: {
dialogLabel: "Cookie settings", dialogLabel: "Cookie settings",
bannerTextBeforeLink: bannerTextBeforeLink:
"We only use technically necessary cookies (e.g. for logging into your customer account). With your consent, we also use cookies for audience measurement. Details can be found in our ", "We use technically necessary cookies (e.g. for logging into your customer account). With your consent, we also remember in your browser which products you viewed recently. We do not use tracking or advertising. Details can be found in our ",
policyLinkLabel: "Cookie Policy", policyLinkLabel: "Cookie Policy",
bannerTextAfterLink: ".", bannerTextAfterLink: ".",
essentialOnly: "Necessary only", essentialOnly: "Necessary only",
@@ -200,7 +226,7 @@ export const en: Dictionary = {
emailLabel: "Email", emailLabel: "Email",
sections: [ sections: [
{ {
heading: "Information pursuant to § 5 TMG (German Telemedia Act)", heading: "Information pursuant to § 5 DDG (German Digital Services Act)",
paragraphs: [ paragraphs: [
"Owner: Nick Rosenlöcher", "Owner: Nick Rosenlöcher",
"Legal form: sole proprietorship (Einzelunternehmen)", "Legal form: sole proprietorship (Einzelunternehmen)",
@@ -215,13 +241,13 @@ export const en: Dictionary = {
{ {
heading: "Editorially Responsible", heading: "Editorially Responsible",
paragraphs: [ paragraphs: [
"Responsible for editorial content pursuant to § 18(2) MStV (German Interstate Media Treaty), including the articles in our \"Honey Knowledge\" section: Nick Rosenlöcher.", "Responsible for editorial content pursuant to § 18(2) MStV (German Interstate Media Treaty), including the articles in our \"Honey Knowledge\" section: Nick Rosenlöcher, address as above.",
], ],
}, },
{ {
heading: "Liability for Content", heading: "Liability for Content",
paragraphs: [ paragraphs: [
"As a service provider, we are responsible for our own content on these pages under the general laws pursuant to § 7(1) TMG. However, pursuant to §§ 8 to 10 TMG, we as a service provider are not obligated to monitor transmitted or stored third-party information or to investigate circumstances that indicate unlawful activity. Obligations to remove or block the use of information under general law remain unaffected. However, liability in this regard is only possible from the point in time at which we become aware of a specific infringement. Upon becoming aware of any such infringements, we will remove the relevant content immediately.", "As a service provider, we are responsible for our own content on these pages under the general laws pursuant to § 7(1) DDG. However, pursuant to §§ 8 to 10 DDG, we as a service provider are not obligated to monitor transmitted or stored third-party information or to investigate circumstances that indicate unlawful activity. Obligations to remove or block the use of information under general law remain unaffected. However, liability in this regard is only possible from the point in time at which we become aware of a specific infringement. Upon becoming aware of any such infringements, we will remove the relevant content immediately.",
], ],
}, },
{ {
@@ -252,7 +278,7 @@ export const en: Dictionary = {
{ {
heading: "1. Data Controller", heading: "1. Data Controller",
paragraphs: [ paragraphs: [
"Based on the size and nature of our business, we do not believe we are required to appoint a data protection officer under § 38 BDSG. For any data protection questions, please use the contact address given in our legal notice.", "Based on the size and nature of our business, we do not believe we are required to appoint a data protection officer under § 38 BDSG. For any data protection questions, please use the contact address given above.",
], ],
}, },
{ {
@@ -264,7 +290,7 @@ export const en: Dictionary = {
{ {
heading: "3. Server Log Files When Visiting the Website", heading: "3. Server Log Files When Visiting the Website",
paragraphs: [ paragraphs: [
"When you access our website, our hosting provider automatically collects technical access data (e.g. IP address, date and time of access, page accessed, browser type, referrer URL) in what are known as server log files. This processing is technically necessary to provide the website (Art. 6(1)(f) GDPR) and is deleted automatically after a short time.", "When you access our website, we automatically collect on our server technical access data (e.g. IP address, date and time of access, page accessed, browser type, referrer URL) in what are known as server log files. This processing is technically necessary to provide the website (Art. 6(1)(f) GDPR) The log files are not combined with other data and are deleted automatically once they are no longer needed for security and error analysis, after 14 days at the latest.",
], ],
}, },
{ {
@@ -273,14 +299,17 @@ export const en: Dictionary = {
"For shipping, we pass your name and delivery address to our shipping provider DHL (DHL Paket GmbH, Bonn, Germany). If you choose delivery to a Packstation or branch, the location number you selected is added. If you search for a Packstation during checkout, the town or postcode you enter is sent to DHL to perform that search. When you check your shipment under “Track order”, we request its status from DHL using the tracking number and the delivery postcode and show it to you directly on our site. Legal basis: Art. 6(1)(b) GDPR.", "For shipping, we pass your name and delivery address to our shipping provider DHL (DHL Paket GmbH, Bonn, Germany). If you choose delivery to a Packstation or branch, the location number you selected is added. If you search for a Packstation during checkout, the town or postcode you enter is sent to DHL to perform that search. When you check your shipment under “Track order”, we request its status from DHL using the tracking number and the delivery postcode and show it to you directly on our site. Legal basis: Art. 6(1)(b) GDPR.",
"When you place an order, we collect the data necessary for contract, payment, and shipping processing (name, address, email, phone number if provided, and the items ordered). The legal basis is Art. 6(1)(b) GDPR (contract performance).", "When you place an order, we collect the data necessary for contract, payment, and shipping processing (name, address, email, phone number if provided, and the items ordered). The legal basis is Art. 6(1)(b) GDPR (contract performance).",
"Payments are processed by our payment provider, Stripe Payments Europe, Ltd. The data necessary for payment processing is transmitted to Stripe for this purpose. Stripe's privacy policy also applies (stripe.com/privacy). Where this involves transferring data to Stripe-affiliated companies outside the EU/EEA (in particular Stripe, Inc. in the USA), this is done on the basis of EU Standard Contractual Clauses pursuant to Art. 46 GDPR.", "Payments are processed by our payment provider, Stripe Payments Europe, Ltd. The data necessary for payment processing is transmitted to Stripe for this purpose. Stripe's privacy policy also applies (stripe.com/privacy). Where this involves transferring data to Stripe-affiliated companies outside the EU/EEA (in particular Stripe, Inc. in the USA), this is done on the basis of EU Standard Contractual Clauses pursuant to Art. 46 GDPR.",
"If you choose PayPal or Klarna in the payment step, you are redirected to the respective provider and the order data required for payment is transmitted to them: PayPal (Europe) S.à r.l. et Cie, S.C.A., 22–24 Boulevard Royal, L-2449 Luxembourg, or Klarna Bank AB (publ), Sveavägen 46, 111 34 Stockholm, Sweden. These providers may carry out their own checks, e.g. of identity or creditworthiness; they are responsible for these themselves and their privacy notices apply. Legal basis: Art. 6(1)(b) GDPR.",
"Order and invoice data is retained for the legally required period due to commercial and tax record-keeping obligations (§ 257 HGB, § 147 AO of German law).", "Order and invoice data is retained for the legally required period due to commercial and tax record-keeping obligations (§ 257 HGB, § 147 AO of German law).",
"We send order confirmations and other emails about your contract or customer account via Microsoft 365 (Microsoft Ireland Operations Limited, One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Ireland). Microsoft processes the data as our processor under a contract pursuant to Art. 28 GDPR. Data may be transferred to Microsoft Corporation in the USA; Microsoft is certified under the EU-US Data Privacy Framework. Legal basis: Art. 6(1)(b) GDPR.",
"If you withdraw from a contract via our online withdrawal function, we store your name, order number, email address, your optional message and the time of receipt, send you a confirmation of receipt and match the withdrawal to your order. Legal basis: Art. 6(1)(c) GDPR in conjunction with § 356a BGB. We keep the notice as proof until the limitation periods for claims under the contract have expired.",
], ],
}, },
{ {
heading: "5. Customer Account", heading: "5. Customer Account",
paragraphs: [ paragraphs: [
"You can alternatively sign in with a Microsoft account (Microsoft Entra ID). You are redirected to Microsoft and sign in there; we never receive a password, only your email address and name in order to match your customer account. Which data Microsoft itself processes during sign-in is governed by Microsoft's privacy notice. The provider is Microsoft Ireland Operations Limited; a transfer to Microsoft Corporation in the USA may take place. Microsoft is certified under the EU-US Data Privacy Framework. Legal basis: Art. 6(1)(b) GDPR.",
"If you create a customer account, we store the data you provide (name, email, password encrypted as a hash) as well as any delivery addresses you optionally save, to give you access to your order history, invoices, and a faster checkout. The legal basis is Art. 6(1)(b) GDPR. You can have your account and associated data deleted at any time via an informal request to us.", "If you create a customer account, we store the data you provide (name, email, password encrypted as a hash) as well as any delivery addresses you optionally save, to give you access to your order history, invoices, and a faster checkout. The legal basis is Art. 6(1)(b) GDPR. You can have your account and associated data deleted at any time via an informal request to us.",
"You can alternatively sign in with a Microsoft account (Microsoft Entra ID). You are redirected to Microsoft and sign in there; we never receive a password, only your email address and name in order to match your customer account. Which data Microsoft itself processes during sign-in is governed by Microsoft's privacy notice. The provider is Microsoft Ireland Operations Limited; a transfer to Microsoft Corporation in the USA may take place. Microsoft is certified under the EU-US Data Privacy Framework. Legal basis: Art. 6(1)(b) GDPR.",
"A technically necessary cookie is set for login; see our Cookie Policy.", "A technically necessary cookie is set for login; see our Cookie Policy.",
], ],
}, },
@@ -288,6 +317,7 @@ export const en: Dictionary = {
heading: "6. Newsletter (Harvest Mail)", heading: "6. Newsletter (Harvest Mail)",
paragraphs: [ paragraphs: [
"If you sign up for our harvest mail, we use your email address exclusively to send information about new harvests. The legal basis is your consent (Art. 6(1)(a) GDPR). You can unsubscribe at any time via the link in every email or through your customer account.", "If you sign up for our harvest mail, we use your email address exclusively to send information about new harvests. The legal basis is your consent (Art. 6(1)(a) GDPR). You can unsubscribe at any time via the link in every email or through your customer account.",
"Sign-up uses a double opt-in: you are only added once you click the link in our confirmation email; we store the time of this confirmation as proof of your consent. If you subscribe in your customer account, the click there constitutes your consent and we store its time as well. Unsubscribing withdraws your consent with effect for the future (Art. 7(3) GDPR). We then keep your address together with the time of unsubscribing so that we can demonstrate your consent and its withdrawal and send you no further harvest mail (Art. 6(1)(f) GDPR).",
], ],
}, },
{ {
@@ -312,14 +342,36 @@ export const en: Dictionary = {
], ],
}, },
{ {
heading: "10. Cookies", heading: "10. Product Reviews",
paragraphs: [
"If you write a review while signed in, we store the star rating, text, time and the link to your customer account. Once approved by us, the review appears on the product page together with your first name and the date. Legal basis: Art. 6(1)(b) GDPR; the review is kept until you ask us to delete it or your customer account is deleted.",
],
},
{
heading: "11. Back-in-Stock Notification",
paragraphs: [
"If you ask to be notified when a sold-out product is available again, we store your email address, the product and your language in order to send you exactly one notification once it is back in stock. Legal basis: your request (Art. 6(1)(b) GDPR). You can have the request deleted at any time by contacting us.",
],
},
{
heading: "12. Cookies",
paragraphs: ["Details on the cookies used on this website can be found in our Cookie Policy."], paragraphs: ["Details on the cookies used on this website can be found in our Cookie Policy."],
}, },
{ {
heading: "11. Your Rights", heading: "13. Storage Period and Obligation to Provide Data",
paragraphs: [
"We store personal data only as long as necessary for the respective purpose or as required by statutory retention periods (for order and invoice data up to ten years under § 147 AO, § 257 HGB). The data is deleted afterwards.",
"The details required for an order are needed to conclude and perform the contract; without them no order is possible. All other details (e.g. newsletter, reviews) are voluntary.",
"No automated decision-making including profiling within the meaning of Art. 22 GDPR takes place.",
],
},
{
heading: "14. Your Rights",
paragraphs: [ paragraphs: [
"You have the right to access (Art. 15 GDPR), rectification (Art. 16 GDPR), erasure (Art. 17 GDPR), restriction of processing (Art. 18 GDPR), data portability (Art. 20 GDPR), and objection to processing (Art. 21 GDPR) of your personal data.", "You have the right to access (Art. 15 GDPR), rectification (Art. 16 GDPR), erasure (Art. 17 GDPR), restriction of processing (Art. 18 GDPR), data portability (Art. 20 GDPR), and objection to processing (Art. 21 GDPR) of your personal data.",
"You also have the right to lodge a complaint with a data protection supervisory authority, e.g. the Bavarian State Office for Data Protection Supervision.", "You also have the right to lodge a complaint with a data protection supervisory authority, e.g. the Bavarian State Office for Data Protection Supervision (BayLDA), Promenade 18, 91522 Ansbach, Germany, www.lda.bayern.de.",
"If you have given consent, you may withdraw it at any time with effect for the future (Art. 7(3) GDPR). This does not affect the lawfulness of processing carried out before the withdrawal.",
"Right to object: where we process data on the basis of legitimate interests (Art. 6(1)(f) GDPR), you may object at any time on grounds relating to your particular situation (Art. 21(1) GDPR). We will then no longer process the data unless we can demonstrate compelling legitimate grounds. You may object to processing for direct marketing at any time without giving reasons (Art. 21(2) GDPR). An informal message to us is sufficient.",
], ],
}, },
], ],
@@ -337,6 +389,7 @@ export const en: Dictionary = {
heading: "2. Formation of Contract", heading: "2. Formation of Contract",
paragraphs: [ paragraphs: [
"The presentation of products in the shop does not constitute a binding offer, but rather a non-binding invitation for you to place an order. By completing an order, you submit a binding offer to purchase, which we accept by confirming it via email or by delivering the goods.", "The presentation of products in the shop does not constitute a binding offer, but rather a non-binding invitation for you to place an order. By completing an order, you submit a binding offer to purchase, which we accept by confirming it via email or by delivering the goods.",
"Until payment is completed you can correct your entries in the order form at any time; from the payment step, “Back to your details” returns you to the form. The contract is concluded in German. We store the contract text; you receive the order details with the confirmation email, and with a customer account you can view your orders there at any time. You can access, print and save these terms on this page at any time.",
], ],
}, },
{ {
@@ -346,18 +399,18 @@ export const en: Dictionary = {
{ {
heading: "4. Payment Methods", heading: "4. Payment Methods",
paragraphs: [ paragraphs: [
"We offer PayPal, credit card, Apple Pay, and Google Pay via our payment provider Stripe. Payment by bank transfer is available on request. See our \"Shipping & Payment\" page and our Privacy Policy for details on Stripe.", "Payment is processed by our payment provider Stripe. The payment methods available (e.g. credit card, SEPA direct debit, Klarna, PayPal, Apple Pay, Google Pay) are shown during checkout and on our \"Shipping & Payment\" page. Payment by bank transfer is available on request.",
"The purchase price is generally due immediately upon ordering (prepayment via the chosen payment method), unless otherwise agreed.", "The purchase price is generally due immediately upon ordering (prepayment via the chosen payment method), unless otherwise agreed.",
], ],
}, },
{ {
heading: "5. Delivery and Pickup", heading: "5. Delivery and Pickup",
paragraphs: ["We deliver within Germany. Alternatively, goods can be picked up directly from us in Altenmünster by arrangement."], paragraphs: ["We deliver to the delivery area stated on our \"Shipping & Payment\" page. Alternatively, goods can be picked up directly from us in Altenmünster by arrangement."],
}, },
{ {
heading: "6. Right of Withdrawal", heading: "6. Right of Withdrawal",
paragraphs: [ paragraphs: [
"Consumers generally have a statutory right of withdrawal. For details, in particular exceptions for perishable goods such as honey, please see our separate Right of Withdrawal page.", "Consumers generally have a statutory right of withdrawal. For details, in particular the statutory exceptions, please see our separate Right of Withdrawal page.",
], ],
}, },
{ {
@@ -391,25 +444,40 @@ export const en: Dictionary = {
title: "Right of Withdrawal", title: "Right of Withdrawal",
sections: [ sections: [
{ {
// Translation of the official German model (see de.ts); the German
// version is the legally binding one.
heading: "Right of Withdrawal", heading: "Right of Withdrawal",
paragraphs: [ paragraphs: [
"You have the right to withdraw from this contract within fourteen days without giving any reason. The withdrawal period is fourteen days from the day on which you, or a third party named by you, took possession of the goods.", "You have the right to withdraw from this contract within fourteen days without giving any reason.",
"To exercise your right of withdrawal, you must inform us of your decision to withdraw from this contract by means of a clear statement (e.g. by email).", "The withdrawal period is fourteen days from the day on which you, or a third party other than the carrier named by you, took possession of the goods.",
"To exercise your right of withdrawal, you must inform us ({{anbieter}}) of your decision to withdraw from this contract by means of a clear statement (e.g. a letter sent by post or an email). You may use the model withdrawal form below, but it is not obligatory.",
"You can also exercise your right of withdrawal online at {{widerrufsfunktion}}. If you use this online function, we will send you a confirmation of receipt on a durable medium (e.g. by email) without undue delay.",
"To meet the withdrawal deadline, it is sufficient for you to send your communication concerning your exercise of the right of withdrawal before the withdrawal period has expired.",
], ],
}, },
{ {
heading: "Consequences of Withdrawal", heading: "Consequences of Withdrawal",
paragraphs: [ paragraphs: [
"If you withdraw from this contract, we will refund all payments we received from you without undue delay, and no later than fourteen days from the day on which we received notice of your withdrawal.", "If you withdraw from this contract, we shall reimburse to you all payments received from you, including the costs of delivery (with the exception of the supplementary costs resulting from your choice of a type of delivery other than the least expensive type of standard delivery offered by us), without undue delay and in any event not later than fourteen days from the day on which we are informed about your decision to withdraw from this contract. We will carry out such reimbursement using the same means of payment as you used for the initial transaction, unless you have expressly agreed otherwise; in any event, you will not incur any fees as a result of such reimbursement.",
// Siehe Begruendung in de.ts — Wortlaut der amtlichen "We may withhold reimbursement until we have received the goods back or you have supplied evidence of having sent back the goods, whichever is the earliest.",
// Muster-Widerrufsbelehrung, hier nur uebersetzt. "You shall send back the goods or hand them over to us without undue delay and in any event not later than fourteen days from the day on which you communicate your withdrawal from this contract to us. The deadline is met if you send back the goods before the period of fourteen days has expired.",
"You bear the direct cost of returning the goods.", "You bear the direct cost of returning the goods.",
"You are only liable for any diminished value of the goods resulting from handling other than what is necessary to establish the nature, characteristics and functioning of the goods.",
], ],
}, },
{ {
heading: "Exclusion of the Right of Withdrawal", heading: "Exclusion of the Right of Withdrawal",
paragraphs: [ paragraphs: [
"The right of withdrawal does not apply to the delivery of goods that can spoil quickly or whose expiry date would soon be exceeded, nor to sealed goods that are not suitable for return for health or hygiene reasons if their seal has been removed after delivery.", "The right of withdrawal does not apply to contracts for the supply of sealed goods which are not suitable for return due to health protection or hygiene reasons if they were unsealed after delivery (§ 312g(2) No. 3 BGB). This applies in particular to opened honey jars whose tamper-evident seal has been broken.",
],
},
{
heading: "Model withdrawal form",
paragraphs: [
"(Complete and return this form only if you wish to withdraw from the contract.)",
"To: {{anbieter}}",
"- I/We (*) hereby give notice that I/We (*) withdraw from my/our (*) contract of sale of the following goods (*)/for the provision of the following service (*)\n- Ordered on (*)/received on (*)\n- Name of consumer(s)\n- Address of consumer(s)\n- Signature of consumer(s) (only if this form is notified on paper)\n- Date",
"(*) Delete as appropriate.",
], ],
}, },
], ],
@@ -451,13 +519,13 @@ export const en: Dictionary = {
heading: "Legal basis", heading: "Legal basis",
paragraphs: [ paragraphs: [
"Storing information on your device or reading it from there generally requires your consent (§ 25(1) TDDDG). No consent is needed where the storage is strictly necessary for us to provide a service you have explicitly requested (§ 25(2) No. 2 TDDDG), such as signing in to your customer account or completing an order.", "Storing information on your device or reading it from there generally requires your consent (§ 25(1) TDDDG). No consent is needed where the storage is strictly necessary for us to provide a service you have explicitly requested (§ 25(2) No. 2 TDDDG), such as signing in to your customer account or completing an order.",
"Where personal data is processed in the process, we rely on Art. 6(1)(b) GDPR (contract and pre-contractual steps, e.g. customer account and order) or Art. 6(1)(f) GDPR (our legitimate interest in a secure, functioning website). Should we use technologies requiring consent in the future, the legal basis will be your consent under § 25(1) TDDDG in conjunction with Art. 6(1)(a) GDPR.", "Where personal data is processed in the process, we rely on Art. 6(1)(b) GDPR (contract and pre-contractual steps, e.g. customer account and order) or Art. 6(1)(f) GDPR (our legitimate interest in a secure, functioning website). For storage requiring consent (see “Cookies requiring consent”), the legal basis is your consent under § 25(1) TDDDG in conjunction with Art. 6(1)(a) GDPR.",
], ],
}, },
{ {
heading: "Overview", heading: "Overview",
paragraphs: [ paragraphs: [
"We only use technically necessary cookies and storage entries. We do not use analytics, statistics, marketing or tracking cookies, we do not build usage profiles, and we do not pass on data to third parties for advertising purposes.", "We use technically necessary cookies and storage entries and — only with your consent — one further storage entry for the list of recently viewed products. We do not use analytics, statistics, marketing or tracking cookies, we do not build usage profiles, and we do not pass on data to third parties for advertising purposes.",
"Most of the cookies listed below are only created when you use a specific feature (e.g. signing in, paying, switching language). Merely visiting the website sets no cookie.", "Most of the cookies listed below are only created when you use a specific feature (e.g. signing in, paying, switching language). Merely visiting the website sets no cookie.",
], ],
}, },
@@ -486,25 +554,25 @@ export const en: Dictionary = {
heading: "Local storage (localStorage)", heading: "Local storage (localStorage)",
paragraphs: [ paragraphs: [
"The following entries are kept in your browser's local storage. Unlike cookies, they are not automatically sent to us with every page request and do not expire on their own: they remain until you delete them (via your browser's “clear site data” setting) or the website removes them. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) or (f) GDPR.", "The following entries are kept in your browser's local storage. Unlike cookies, they are not automatically sent to us with every page request and do not expire on their own: they remain until you delete them (via your browser's “clear site data” setting) or the website removes them. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) or (f) GDPR.",
"wabenhain-cart – Your cart (items, quantities, prices). It is only transmitted to us when you submit an order, and it is emptied after a successful order.", "wabenhain-cart – Your cart (items, quantities, prices). It is only transmitted to us when you submit an order, and it is emptied after a successful order. Storage period: until your next order or until you empty it yourself.",
"wabenhain-recently-viewed – The identifiers of up to 6 recently viewed products, so they can be shown to you again on product pages. Only these product identifiers are requested for display; we store no browsing history on our servers.", "wabenhain-cookie-consent – Your choice in the cookie banner (“Essential only” or “Accept all”), so the banner does not reappear on every visit and we can demonstrate your decision. Storage period: until you change your choice or clear the site data. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(c) GDPR in conjunction with Art. 7(1) GDPR.",
"wabenhain-cookie-consent – Your choice in the cookie banner (“Essential only” or “Accept all”), so the banner does not reappear on every visit and we can demonstrate your decision. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(c) GDPR in conjunction with Art. 7(1) GDPR.",
], ],
}, },
{ {
heading: "Third-party cookies", heading: "Third-party cookies",
paragraphs: [ paragraphs: [
"Stripe (payment processing): Only in the payment step of checkout does your browser load Stripe's payment form. Stripe sets its own cookies for fraud prevention and secure payment processing, in particular __stripe_mid (storage period 1 year) and __stripe_sid (storage period 30 minutes), as well as further cookies within the payment form on Stripe domains. Stripe determines the names and lifetimes of these cookies. Provider: Stripe Payments Europe, Ltd., 1 Grand Canal Street Lower, Dublin 2, Ireland. Data may be transferred to Stripe, Inc. in the USA on the basis of EU standard contractual clauses (Art. 46 GDPR). Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) GDPR. More information: stripe.com/privacy and stripe.com/legal/cookies-policy.", "Stripe (payment processing): Only in the payment step of checkout does your browser load Stripe's payment form. Stripe sets its own cookies for fraud prevention and secure payment processing, in particular __stripe_mid (storage period 1 year) and __stripe_sid (storage period 30 minutes), as well as further cookies within the payment form on Stripe domains. Stripe determines the names and lifetimes of these cookies. Provider: Stripe Payments Europe, Ltd., 1 Grand Canal Street Lower, Dublin 2, Ireland. Data may be transferred to Stripe, Inc. in the USA on the basis of EU standard contractual clauses (Art. 46 GDPR). Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) GDPR. More information: stripe.com/privacy and stripe.com/legal/cookies-policy.",
"Microsoft (sign-in with a Microsoft account): If you choose “Sign in with Microsoft”, you are redirected to a Microsoft sign-in page. Microsoft (Microsoft Ireland Operations Limited) is responsible for the cookies set there; Microsoft's privacy statement applies. Microsoft sets no cookies on our website.", "Microsoft (sign-in with a Microsoft account): If you choose “Sign in with Microsoft”, you are redirected to a Microsoft sign-in page. Microsoft is responsible for the cookies set there; Microsoft's privacy statement applies. Microsoft sets no cookies on our website. Provider: Microsoft Ireland Operations Limited, One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Ireland.",
"Cloudflare Turnstile (form protection): On pages with forms (sign-in, registration, password reset, two-factor verification, checkout, newsletter, business enquiry, back-in-stock notification), your browser loads a script from challenges.cloudflare.com. It distinguishes input made by humans from input made by automated programs. Your IP address and technical characteristics of your browser are transmitted to Cloudflare, Inc. (USA) in the process. According to the provider, Turnstile sets no advertising cookies and builds no cross-site user profile. Cloudflare is certified under the EU-US Data Privacy Framework. Details are in our privacy policy.", "Cloudflare Turnstile (form protection): On pages with forms (sign-in, registration, password reset, two-factor verification, checkout, newsletter, business enquiry, back-in-stock notification), your browser loads a script from challenges.cloudflare.com. It distinguishes input made by humans from input made by automated programs. Your IP address and technical characteristics of your browser are transmitted to Cloudflare, Inc. (USA) in the process. According to the provider, Turnstile sets no advertising cookies and builds no cross-site user profile. Cloudflare is certified under the EU-US Data Privacy Framework. Provider: Cloudflare, Inc., 101 Townsend Street, San Francisco, CA 94107, USA. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(f) GDPR. More information: see the “Cloudflare Turnstile” section of our privacy policy.",
"Google reviews: We retrieve the reviews on our homepage server-side via the Google Places API. Your browser does not connect to Google, and Google sets no cookie.", "Google reviews: We retrieve the reviews on our homepage server-side via the Google Places API. Your browser does not connect to Google, and Google sets no cookie.",
], ],
}, },
{ {
heading: "Cookies requiring consent", heading: "Cookies requiring consent",
paragraphs: [ paragraphs: [
"We currently use no cookies or technologies that require your consent. Choosing “Accept all” in the cookie banner therefore enables nothing additional at present; with “Essential only” you can use the website without any restriction.", "We only store the following entry if you choose “Accept all” in the cookie banner. Without consent it is not created; the list then only lasts while the tab is open. If you choose “Essential only” or withdraw your consent, we delete the entry immediately. Legal basis: § 25(1) TDDDG in conjunction with Art. 6(1)(a) GDPR.",
"Should we introduce something like audience measurement in the future, we will name it here beforehand with provider, purpose and storage period, and only activate it after your explicit, voluntary consent via the cookie banner. Declining has no disadvantages for you.", "wabenhain-recently-viewed – The identifiers of up to 6 recently viewed products (localStorage), so they can be shown to you again on product pages. Only these product identifiers are requested for display; we store no browsing history on our servers. Storage period: until you withdraw your consent or clear the site data in your browser.",
"We do not use any other technologies requiring consent, such as audience measurement. Should that change, we will name them here beforehand with provider, purpose and storage period, and only activate it after your explicit, voluntary consent via the cookie banner. Declining has no disadvantages for you.",
], ],
}, },
{ {
@@ -1163,6 +1231,37 @@ export const en: Dictionary = {
sectionVisibility: "Visibility", sectionVisibility: "Visibility",
create: "Create", create: "Create",
}, },
adminEmailTestPage: {
title: "Test email delivery",
intro: "Gets a token via the “Sign in with Microsoft” app registration, signs in to Microsoft 365 as SMTP_USER and sends a test email to your own address. If something fails, you see the mail server message and what to do (the explanation is in German).",
button: "Send test email",
running: "Testing …",
success: (to) => `Delivery works. The test email is on its way to ${to}.`,
notConfigured: "Email delivery is not fully set up. Without the values listed below (Kubernetes config map or secret), emails are only written to the server log.",
failureHeading: "Delivery failed",
causeLabel: "What to do",
detailLabel: "Mail server message",
configLabel: "Settings used",
authModeOAuth: "Microsoft 365, OAuth 2.0 via the SSO app",
missingLabel: "Missing settings",
},
adminWithdrawalsPage: {
title: "Withdrawals",
intro: "Notices submitted via the online withdrawal function (§ 356a BGB). The refund is due no later than 14 days after receipt; you may withhold it until the goods are back or proof of return has been provided.",
backToDashboard: "Back to dashboard",
empty: "No withdrawals received yet.",
statusOpen: "Open",
statusProcessed: "Done",
matched: "Order matched",
unmatched: "No matching order — please check",
mailSent: "Confirmation of receipt sent",
mailNotSent: "Confirmation of receipt NOT sent — please send manually",
receivedAt: "Received",
processedOn: (date) => `Done on ${date}`,
markProcessed: "Mark as done",
reopen: "Reopen",
viewOrder: "View order",
},
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Refund ${amount} for order ${orderNumber} via Stripe now? This cannot be undone.`, refundConfirm: (orderNumber, amount) => `Refund ${amount} for order ${orderNumber} via Stripe now? This cannot be undone.`,
rejectConfirm: (orderNumber) => `Really reject the return for order ${orderNumber}?`, rejectConfirm: (orderNumber) => `Really reject the return for order ${orderNumber}?`,
@@ -1375,6 +1474,7 @@ export const en: Dictionary = {
navDashboard: "Dashboard", navDashboard: "Dashboard",
navOrders: "Orders", navOrders: "Orders",
navReturns: "Returns", navReturns: "Returns",
navWithdrawals: "Withdrawals",
navProducts: "Products", navProducts: "Products",
navHarvests: "Harvests", navHarvests: "Harvests",
navBatches: "Batches", navBatches: "Batches",
@@ -1384,6 +1484,7 @@ export const en: Dictionary = {
navVouchers: "Vouchers", navVouchers: "Vouchers",
navReviews: "Reviews", navReviews: "Reviews",
navAuditLog: "Audit log", navAuditLog: "Audit log",
navEmailTest: "Email test",
navContent: "Content", navContent: "Content",
navAriaLabel: "Admin navigation", navAriaLabel: "Admin navigation",
mobileNavAriaLabel: "Mobile admin navigation", mobileNavAriaLabel: "Mobile admin navigation",
+59
View File
@@ -192,6 +192,32 @@ export interface Dictionary {
}; };
legalPage: { legalPage: {
reviewNotice: string; reviewNotice: string;
tocLabel: string;
revisionLabel: string;
};
withdrawalPage: {
title: string;
intro: string;
nameLabel: string;
orderNumberLabel: string;
orderNumberHint: string;
emailLabel: string;
emailHint: string;
messageLabel: string;
requiredNote: string;
startButton: string;
reviewHeading: string;
reviewIntro: string;
confirmButton: string;
editButton: string;
submitting: string;
successHeading: string;
successMailSent: string;
successMailFailed: string;
receivedAtLabel: string;
errorMessage: string;
policyLink: string;
footerButton: string;
}; };
notFoundPage: { notFoundPage: {
title: string; title: string;
@@ -792,6 +818,37 @@ export interface Dictionary {
sectionVisibility: string; sectionVisibility: string;
create: string; create: string;
}; };
adminEmailTestPage: {
title: string;
intro: string;
button: string;
running: string;
success: (to: string) => string;
notConfigured: string;
failureHeading: string;
causeLabel: string;
detailLabel: string;
configLabel: string;
authModeOAuth: string;
missingLabel: string;
};
adminWithdrawalsPage: {
title: string;
intro: string;
backToDashboard: string;
empty: string;
statusOpen: string;
statusProcessed: string;
matched: string;
unmatched: string;
mailSent: string;
mailNotSent: string;
receivedAt: string;
processedOn: (date: string) => string;
markProcessed: string;
reopen: string;
viewOrder: string;
};
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber: string, amount: string) => string; refundConfirm: (orderNumber: string, amount: string) => string;
rejectConfirm: (orderNumber: string) => string; rejectConfirm: (orderNumber: string) => string;
@@ -992,6 +1049,7 @@ export interface Dictionary {
navDashboard: string; navDashboard: string;
navOrders: string; navOrders: string;
navReturns: string; navReturns: string;
navWithdrawals: string;
navProducts: string; navProducts: string;
navHarvests: string; navHarvests: string;
navBatches: string; navBatches: string;
@@ -1001,6 +1059,7 @@ export interface Dictionary {
navVouchers: string; navVouchers: string;
navReviews: string; navReviews: string;
navAuditLog: string; navAuditLog: string;
navEmailTest: string;
navContent: string; navContent: string;
navAriaLabel: string; navAriaLabel: string;
mobileNavAriaLabel: string; mobileNavAriaLabel: string;
+8 -1
View File
@@ -2,6 +2,9 @@ import { cache } from "react";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { getContentValues } from "@/lib/content"; import { getContentValues } from "@/lib/content";
/** Bewusst schlicht: nur "[email protected]" ohne Leerzeichen — faengt Verschleierungen wie "info [at] x.de" ab. */
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const COMPANY_KEYS = [ const COMPANY_KEYS = [
"company.name", "company.name",
"company.claim", "company.claim",
@@ -46,7 +49,11 @@ export const getCompanyInfo = cache(async () => {
claimAlt: v["company.claimAlt"], claimAlt: v["company.claimAlt"],
description: v["company.description"], description: v["company.description"],
location: v["company.location"], location: v["company.location"],
email: v["company.email"], // Altbestand kann eine verschleierte Adresse enthalten ("info [at] …"),
// die das Admin inzwischen ablehnt. Im Impressum und in der
// Widerrufsbelehrung muss sie direkt nutzbar sein — dann lieber die
// konfigurierte Standardadresse als eine unbrauchbare.
email: EMAIL_PATTERN.test(v["company.email"].trim()) ? v["company.email"].trim() : siteConfig.email,
phone: v["company.phone"], phone: v["company.phone"],
address: { address: {
...siteConfig.address, ...siteConfig.address,
+39
View File
@@ -0,0 +1,39 @@
/**
* Cookie-/Speicher-Einwilligung (§ 25 Abs. 1 TDDDG). Die Auswahl aus dem
* Banner liegt im localStorage; nur "all" erlaubt einwilligungspflichtige
* Speicherungen. Nur im Browser aufrufen.
*/
export const COOKIE_CONSENT_KEY = "wabenhain-cookie-consent";
/**
* localStorage-Schluessel des "Zuletzt angesehen"-Verlaufs
* (store/recently-viewed-store.ts) — einwilligungspflichtig, weil die
* Funktion fuer den Einkauf nicht unbedingt erforderlich ist.
*/
export const RECENTLY_VIEWED_KEY = "wabenhain-recently-viewed";
export type Consent = "all" | "essential";
export function readConsent(): Consent | null {
try {
const value = window.localStorage.getItem(COOKIE_CONSENT_KEY);
return value === "all" || value === "essential" ? value : null;
} catch {
// Gesperrter Speicher (privater Modus, Richtlinie): wie "keine Einwilligung".
return null;
}
}
export function hasOptionalConsent(): boolean {
return readConsent() === "all";
}
/** Entfernt alle einwilligungspflichtigen Eintraege — bei "Nur notwendige" und beim Widerruf. */
export function clearOptionalStorage(): void {
try {
window.localStorage.removeItem(RECENTLY_VIEWED_KEY);
} catch {
// Nichts zu entfernen, wenn der Speicher nicht zugaenglich ist.
}
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/company-info", () => ({
getCompanyInfo: async () => ({
name: "WABENHAIN <b>",
address: { street: "Bühlweg 10", zip: "86450", city: "Altenmünster" },
}),
}));
vi.mock("@/lib/url", () => ({ getSiteUrl: () => "https://wabenhain.com" }));
import {
backInStockEmail,
orderConfirmationEmail,
pickupReadyEmail,
returnAcceptedEmail,
shipmentEmail,
} from "./email-templates";
const EVIL = `<a href="https://phish.example">Hier klicken</a>`;
const ESCAPED = "&lt;a href=&quot;https://phish.example&quot;&gt;Hier klicken&lt;/a&gt;";
describe("email templates escape untrusted values", () => {
it("order confirmation escapes name, items, address and order number", async () => {
const mail = await orderConfirmationEmail({
locale: "de",
orderNumber: `WH-1"><img src=x>`,
firstName: EVIL,
fulfillmentMethod: "VERSAND",
items: [{ productName: EVIL, quantity: 1, weightGrams: 500, totalCents: 1200 }],
subtotalCents: 1200,
shippingCents: 0,
totalCents: 1200,
street: EVIL,
zip: "86450",
city: "<script>alert(1)</script>",
});
expect(mail.html).not.toContain("https://phish.example\">");
expect(mail.html).not.toContain("<script>");
expect(mail.html).not.toContain("<img src=x>");
expect(mail.html).toContain(ESCAPED);
// Der Text-Teil bleibt lesbar und unmaskiert.
expect(mail.text).toContain(EVIL);
});
it("shipment mail escapes carrier and tracking number and rejects non-http tracking links", async () => {
const mail = await shipmentEmail({
locale: "de",
orderNumber: "WH-1",
firstName: "Erika",
carrier: EVIL,
trackingNumber: "<b>123</b>",
trackingUrl: "javascript:alert(1)",
});
expect(mail.html).toContain(ESCAPED);
expect(mail.html).toContain("&lt;b&gt;123&lt;/b&gt;");
expect(mail.html).not.toContain("javascript:");
});
it("status mails escape the first name and order number", async () => {
const mail = await returnAcceptedEmail({ locale: "en", orderNumber: "<i>1</i>", firstName: EVIL });
expect(mail.html).toContain(ESCAPED);
expect(mail.html).toContain("&lt;i&gt;1&lt;/i&gt;");
});
it("escapes admin-maintained company data in the layout", async () => {
const mail = await pickupReadyEmail({ locale: "de", orderNumber: "WH-1", firstName: "Erika" });
expect(mail.html).toContain("WABENHAIN &lt;b&gt;");
});
it("escapes product names in back-in-stock mails", async () => {
const mail = await backInStockEmail({ locale: "de", name: EVIL, slug: "honig" });
expect(mail.html).toContain(ESCAPED);
expect(mail.html).not.toContain("https://phish.example\">");
});
});
+45 -22
View File
@@ -2,6 +2,8 @@ import { formatPrice } from "@/lib/format";
import { getSiteUrl } from "@/lib/url"; import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
import { getEmailStrings } from "@/lib/email-strings"; import { getEmailStrings } from "@/lib/email-strings";
import { escapeHtml } from "@/lib/escape-html";
import { withdrawalReceiptEmailContent, type WithdrawalDeclarationInput } from "@/lib/withdrawal";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
interface EmailContent { interface EmailContent {
@@ -15,13 +17,18 @@ interface EmailContent {
* Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele * Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele
* E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig * E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig
* unterstuetzen. * unterstuetzen.
*
* Jeder variable Wert (Kundeneingaben, Bestelldaten, Admin-Felder) wird vor
* dem Einsetzen ins HTML mit escapeHtml maskiert — sonst liesse sich ueber
* z. B. den Vornamen HTML samt Phishing-Link in eine echte Shop-Mail
* einschleusen. Die Text-Variante bleibt unmaskiert (kein HTML).
*/ */
async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> { async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> {
const company = await getCompanyInfo(); const company = await getCompanyInfo();
return `<!doctype html> return `<!doctype html>
<html lang="${locale}"> <html lang="${locale}">
<body style="margin:0;padding:0;background-color:#FAF8F3;font-family:Arial,Helvetica,sans-serif;color:#242424;"> <body style="margin:0;padding:0;background-color:#FAF8F3;font-family:Arial,Helvetica,sans-serif;color:#242424;">
<span style="display:none;font-size:1px;color:#FAF8F3;line-height:1px;">${preheader}</span> <span style="display:none;font-size:1px;color:#FAF8F3;line-height:1px;">${escapeHtml(preheader)}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#FAF8F3;padding:32px 16px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#FAF8F3;padding:32px 16px;">
<tr> <tr>
<td align="center"> <td align="center">
@@ -39,7 +46,7 @@ async function emailLayout(locale: Locale, preheader: string, bodyHtml: string):
</tr> </tr>
<tr> <tr>
<td style="padding:20px 32px;background-color:#F4EFE5;font-size:12px;color:#666;"> <td style="padding:20px 32px;background-color:#F4EFE5;font-size:12px;color:#666;">
${company.name} · ${company.address.street}, ${company.address.zip} ${company.address.city} ${escapeHtml(company.name)} · ${escapeHtml(company.address.street)}, ${escapeHtml(company.address.zip)} ${escapeHtml(company.address.city)}
</td> </td>
</tr> </tr>
</table> </table>
@@ -50,8 +57,10 @@ async function emailLayout(locale: Locale, preheader: string, bodyHtml: string):
</html>`; </html>`;
} }
/** Nur http(s)-Links — ein "javascript:"-Link aus einem Admin-Feld (Tracking) wird weggelassen. */
function button(url: string, label: string): string { function button(url: string, label: string): string {
return `<a href="${url}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${label}</a>`; if (!/^https?:\/\//i.test(url)) return "";
return `<a href="${escapeHtml(url)}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${escapeHtml(label)}</a>`;
} }
interface OrderConfirmationInput { interface OrderConfirmationInput {
@@ -76,13 +85,13 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
const rows = order.items const rows = order.items
.map( .map(
(item) => (item) =>
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>` `<tr><td style="padding:6px 0;">${item.quantity} × ${escapeHtml(item.productName)} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
) )
.join(""); .join("");
const deliveryHtml = const deliveryHtml =
order.fulfillmentMethod === "VERSAND" order.fulfillmentMethod === "VERSAND"
? `<p style="margin:4px 0;">${s.deliveryTo}<br>${order.street}<br>${order.zip} ${order.city}</p>` ? `<p style="margin:4px 0;">${s.deliveryTo}<br>${escapeHtml(order.street)}<br>${escapeHtml(order.zip)} ${escapeHtml(order.city)}</p>`
: `<p style="margin:4px 0;">${s.pickupNote}</p>`; : `<p style="margin:4px 0;">${s.pickupNote}</p>`;
const giftHtml = order.giftMessage const giftHtml = order.giftMessage
@@ -95,8 +104,8 @@ export async function orderConfirmationEmail(order: OrderConfirmationInput): Pro
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 20px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 20px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
<table role="presentation" width="100%" style="font-size:14px;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:8px 0;"> <table role="presentation" width="100%" style="font-size:14px;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:8px 0;">
${rows} ${rows}
</table> </table>
@@ -225,7 +234,7 @@ export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailCon
const trackingHtml = hasTracking const trackingHtml = hasTracking
? ` ? `
<p style="margin:16px 0 0;line-height:1.5;"> <p style="margin:16px 0 0;line-height:1.5;">
${order.carrier}: <strong>${order.trackingNumber}</strong> ${escapeHtml(order.carrier ?? "")}: <strong>${escapeHtml(order.trackingNumber ?? "")}</strong>
</p> </p>
${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""} ${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""}
` `
@@ -234,8 +243,8 @@ export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailCon
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 8px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
${trackingHtml} ${trackingHtml}
` `
); );
@@ -256,10 +265,10 @@ export async function pickupReadyEmail(order: OrderStatusUpdateInput): Promise<E
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 8px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
<p style="margin:16px 0 0;line-height:1.5;"> <p style="margin:16px 0 0;line-height:1.5;">
${company.address.street}<br>${company.address.zip} ${company.address.city} ${escapeHtml(company.address.street)}<br>${escapeHtml(company.address.zip)} ${escapeHtml(company.address.city)}
</p> </p>
` `
); );
@@ -290,7 +299,7 @@ export async function backInStockEmail(product: BackInStockInput): Promise<Email
s.preheader(product.name), s.preheader(product.name),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(product.name)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(product.name))}</p>
${button(url, s.button)} ${button(url, s.button)}
` `
); );
@@ -301,14 +310,28 @@ export async function backInStockEmail(product: BackInStockInput): Promise<Email
}; };
} }
/** Eingangsbestaetigung der elektronischen Widerrufsfunktion (§ 356a Abs. 4 BGB). */
export async function withdrawalReceiptEmail(
input: WithdrawalDeclarationInput,
receivedAt: Date,
locale: Locale
): Promise<EmailContent> {
const content = withdrawalReceiptEmailContent(input, receivedAt, locale);
return {
subject: content.subject,
html: await emailLayout(locale, content.preheader, content.bodyHtml),
text: content.text,
};
}
export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> { export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).returnAccepted; const s = getEmailStrings(order.locale).returnAccepted;
const html = await emailLayout( const html = await emailLayout(
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
` `
); );
return { return {
@@ -324,8 +347,8 @@ export async function returnRejectedEmail(order: OrderStatusUpdateInput): Promis
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
` `
); );
return { return {
@@ -341,8 +364,8 @@ export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promis
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
` `
); );
return { return {
@@ -362,8 +385,8 @@ export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise
order.locale, order.locale,
s.preheader(order.orderNumber), s.preheader(order.orderNumber),
` `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1> <h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(order.firstName))}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p> <p style="margin:0 0 8px;line-height:1.5;">${s.intro(escapeHtml(order.orderNumber))}</p>
<p style="margin:0;line-height:1.5;">${s.retryNote}</p> <p style="margin:0;line-height:1.5;">${s.retryNote}</p>
<div style="margin-top:20px;">${button(shopUrl, s.button)}</div> <div style="margin-top:20px;">${button(shopUrl, s.button)}</div>
` `
+25 -5
View File
@@ -46,15 +46,35 @@ interface EntraConfig {
redirectUri: string; redirectUri: string;
} }
export interface EntraAppCredentials {
tenantId: string;
clientId: string;
clientSecret: string;
}
/**
* Zugangsdaten der EINEN App-Registrierung in Entra ID. Sie dient zwei
* Zwecken: der Anmeldung "Mit Microsoft anmelden" (dieses Modul) und dem
* Mailversand ueber Microsoft 365 per OAuth 2.0 (lib/mail.ts, Berechtigung
* SMTP.SendAsApp) — eine Unternehmensanwendung statt zwei. Beim Secret wird
* nur ein Zeilenumbruch am Ende entfernt (typischer Fehler beim Anlegen des
* Kubernetes-Secrets), sonstige Werte werden getrimmt. null, wenn etwas fehlt.
*/
export function readEntraAppCredentials(): EntraAppCredentials | null {
const tenantId = process.env.ENTRA_TENANT_ID?.trim() ?? "";
const clientId = process.env.ENTRA_CLIENT_ID?.trim() ?? "";
const clientSecret = process.env.ENTRA_CLIENT_SECRET?.replace(/[\r\n]+$/, "") ?? "";
if (!tenantId || !clientId || !clientSecret) return null;
return { tenantId, clientId, clientSecret };
}
function getEntraConfig(): EntraConfig { function getEntraConfig(): EntraConfig {
const tenantId = process.env.ENTRA_TENANT_ID; const credentials = readEntraAppCredentials();
const clientId = process.env.ENTRA_CLIENT_ID;
const clientSecret = process.env.ENTRA_CLIENT_SECRET;
const redirectUri = process.env.ENTRA_REDIRECT_URI; const redirectUri = process.env.ENTRA_REDIRECT_URI;
if (!tenantId || !clientId || !clientSecret || !redirectUri) { if (!credentials || !redirectUri) {
throw new EntraNotConfiguredError(); throw new EntraNotConfiguredError();
} }
return { tenantId, clientId, clientSecret, redirectUri }; return { ...credentials, redirectUri };
} }
/** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */ /** Fuer die bedingte Anzeige des "Mit Microsoft anmelden"-Buttons im Login-UI. */
+14
View File
@@ -0,0 +1,14 @@
/**
* Maskiert Text fuer die Ausgabe in HTML (Element-Inhalt und Attributwerte in
* doppelten oder einfachen Anfuehrungszeichen). Pflicht fuer JEDEN Wert aus
* Nutzereingaben, Datenbank oder Admin-Feldern, der in selbst gebautes HTML
* wandert — vor allem in E-Mails, wo React nicht automatisch maskiert.
*/
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { extractRevision, parseLegalParagraph, sectionAnchors, splitSentences, tokenizeInline } from "./legal-blocks";
describe("parseLegalParagraph", () => {
it("keeps ordinary prose as a text block", () => {
expect(parseLegalParagraph("Cookies sind kleine Textdateien. Sie speichern Einstellungen.")).toEqual({
kind: "text",
text: "Cookies sind kleine Textdateien. Sie speichern Einstellungen.",
});
});
it("turns a paragraph whose lines all start with a dash into a list", () => {
expect(parseLegalParagraph("- Auskunft\n- Berichtigung\n• Löschung")).toEqual({
kind: "list",
items: ["Auskunft", "Berichtigung", "Löschung"],
});
});
it("does not treat a single dashed line mixed with prose as a list", () => {
expect(parseLegalParagraph("Einleitung\n- Punkt").kind).toBe("text");
});
it("recognises a storage entry and pulls labelled facts out of it", () => {
const block = parseLegalParagraph(
"wabenhain_session – Hält dich angemeldet. Inhalt: signierte Kennung, keine Passwörter. Speicherdauer: 30 Tage; wird beim Abmelden gelöscht."
);
expect(block).toEqual({
kind: "entry",
name: "wabenhain_session",
note: null,
text: "Hält dich angemeldet.",
facts: [
{ label: "Inhalt", value: "signierte Kennung, keine Passwörter." },
{ label: "Speicherdauer", value: "30 Tage; wird beim Abmelden gelöscht." },
],
});
});
it("keeps abbreviations such as Art. and Abs. inside one fact", () => {
const block = parseLegalParagraph(
"theme – Speichert die Darstellung. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. f DSGVO. Wird sonst gelöscht."
);
expect(block.kind === "entry" && block.facts).toEqual([
{ label: "Rechtsgrundlage", value: "§ 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. f DSGVO." },
]);
expect(block.kind === "entry" && block.text).toBe("Speichert die Darstellung. Wird sonst gelöscht.");
});
it("recognises a provider entry written as 'Name (Zweck): Text'", () => {
const block = parseLegalParagraph("Stripe (Zahlungsabwicklung): Lädt das Formular. Anbieter: Stripe Payments Europe, Ltd., Dublin.");
expect(block).toMatchObject({ kind: "entry", name: "Stripe", note: "Zahlungsabwicklung", text: "Lädt das Formular." });
expect(block.kind === "entry" && block.facts).toEqual([{ label: "Anbieter", value: "Stripe Payments Europe, Ltd., Dublin." }]);
});
it("recognises English fact labels", () => {
const block = parseLegalParagraph("locale – Stores your language. Storage period: 1 year.");
expect(block.kind === "entry" && block.facts).toEqual([{ label: "Storage period", value: "1 year." }]);
});
it("does not mistake a sentence with a spaced dash for an entry", () => {
expect(parseLegalParagraph("Wir versenden schnell – meist in zwei Tagen.").kind).toBe("text");
});
});
describe("splitSentences", () => {
it("does not split after z. B. or Art.", () => {
expect(splitSentences("Etwa z. B. Chrome. Nach Art. 6 DSGVO. Ende.")).toEqual(["Etwa z. B. Chrome.", "Nach Art. 6 DSGVO.", "Ende."]);
});
});
describe("extractRevision", () => {
it("moves a trailing 'Stand:' line out of the sections", () => {
const { sections, revision } = extractRevision([
{ heading: "A", paragraphs: ["Text"] },
{ heading: "B", paragraphs: ["Mehr", "Stand: September 2026"] },
]);
expect(revision).toBe("September 2026");
expect(sections).toEqual([
{ heading: "A", paragraphs: ["Text"] },
{ heading: "B", paragraphs: ["Mehr"] },
]);
});
it("drops a section that only held the revision line", () => {
const { sections, revision } = extractRevision([
{ heading: "A", paragraphs: ["Text"] },
{ heading: "Stand", paragraphs: ["Last updated: May 2026"] },
]);
expect(revision).toBe("May 2026");
expect(sections).toHaveLength(1);
});
it("returns null when no revision is given", () => {
expect(extractRevision([{ heading: "A", paragraphs: ["Text"] }]).revision).toBeNull();
});
});
describe("sectionAnchors", () => {
it("builds readable, unique anchors from headings", () => {
expect(sectionAnchors(["1. Verantwortlicher", "Deine Rechte", "Deine Rechte", "Über uns & mehr"])).toEqual([
"verantwortlicher",
"deine-rechte",
"deine-rechte-2",
"ueber-uns-mehr",
]);
});
it("falls back to a numbered anchor for headings without letters", () => {
expect(sectionAnchors(["§§", ""])).toEqual(["abschnitt-1", "abschnitt-2"]);
});
});
describe("tokenizeInline", () => {
it("links web addresses with a path and e-mail addresses", () => {
expect(tokenizeInline("Siehe stripe.com/de/privacy oder schreib an [email protected].")).toEqual([
{ kind: "text", value: "Siehe " },
{ kind: "link", value: "stripe.com/de/privacy", href: "https://stripe.com/de/privacy" },
{ kind: "text", value: " oder schreib an " },
{ kind: "link", value: "[email protected]", href: "mailto:[email protected]" },
{ kind: "text", value: "." },
]);
});
it("keeps explicit https links and never links other schemes", () => {
expect(tokenizeInline("https://example.org/a javascript:alert(1)")).toEqual([
{ kind: "link", value: "https://example.org/a", href: "https://example.org/a" },
{ kind: "text", value: " javascript:alert(1)" },
]);
});
it("leaves a bare domain without path as text", () => {
expect(tokenizeInline("wabenhain.com")).toEqual([{ kind: "text", value: "wabenhain.com" }]);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* Liest aus dem Klartext der Rechtsseiten (Admin-Bereich → Inhalte) eine
* leichte Struktur heraus, ohne dass im Admin eine Auszeichnungssprache
* noetig wird. Erkannt wird nur, was eindeutig ist — alles andere bleibt
* ein normaler Absatz, damit eigener Text nie verfaelscht dargestellt wird:
*
* - Absatz, dessen Zeilen ALLE mit "- " oder "• " beginnen → Aufzaehlung
* - "cookie_name – Beschreibung. Speicherdauer: 1 Jahr." → Eintrag mit Fakten
* - "Anbieter (Zweck): Beschreibung. Anbieter: …" → Eintrag mit Fakten
* - "Stand: September 2026" als eigener Absatz → Datum im Seitenkopf
*/
interface LegalSectionContent {
heading: string;
paragraphs: string[];
}
export interface LegalFact {
label: string;
value: string;
}
export type LegalBlock =
| { kind: "text"; text: string }
| { kind: "list"; items: string[] }
| { kind: "entry"; name: string; note: string | null; text: string; facts: LegalFact[] };
export type InlineToken = { kind: "text"; value: string } | { kind: "link"; value: string; href: string };
const LIST_LINE = /^\s*[-•]\s+(.+)$/;
// Technischer Name ohne Leerzeichen, dann " – " (Halbgeviertstrich mit
// Leerzeichen): so schreibt die Cookie-Richtlinie ihre Eintraege.
const CODE_ENTRY = /^([A-Za-z0-9_.-]{3,40}) – ([\s\S]+)$/;
// "Stripe (Zahlungsabwicklung): …" — Anbieter mit Zweck in Klammern.
const LABEL_ENTRY = /^([A-ZÄÖÜ][\wÄÖÜäöüß .&-]{1,30}?) \(([^()]{2,60})\): ([\s\S]+)$/;
const FACT_LABELS = [
"Inhalt",
"Speicherdauer",
"Rechtsgrundlage",
"Anbieter",
"Weitere Informationen",
"Content",
"Storage period",
"Legal basis",
"Provider",
"More information",
];
const FACT = new RegExp(`^(${FACT_LABELS.join("|")}):\\s+([\\s\\S]+)$`);
const REVISION = /^(Stand|Zuletzt aktualisiert|Last updated)\s*:\s*(.+)$/i;
// Kuerzel, nach denen ein Punkt KEIN Satzende ist (Art. 6, Abs. 1, z. B.).
const ABBREVIATIONS = new Set(["art", "abs", "nr", "lit", "bzw", "ggf", "vgl", "ca", "inkl", "evtl", "usw", "etc", "ltd", "inc", "co", "no", "st", "str"]);
export function splitSentences(text: string): string[] {
const sentences: string[] = [];
let start = 0;
for (const match of text.matchAll(/[.!?]\s+/g)) {
const end = match.index + 1;
const next = text[match.index + match[0].length] ?? "";
const word = /([\p{L}\d]+)$/u.exec(text.slice(start, match.index))?.[1] ?? "";
const isAbbreviation = word.length === 1 || /^\d+$/.test(word) || ABBREVIATIONS.has(word.toLowerCase());
if (isAbbreviation || !/[\p{Lu}„"§]/u.test(next)) continue;
sentences.push(text.slice(start, end).trim());
start = match.index + match[0].length;
}
const rest = text.slice(start).trim();
if (rest) sentences.push(rest);
return sentences;
}
function toEntry(name: string, note: string | null, body: string): LegalBlock {
const facts: LegalFact[] = [];
const prose: string[] = [];
for (const sentence of splitSentences(body)) {
const fact = FACT.exec(sentence);
if (fact) facts.push({ label: fact[1], value: fact[2] });
else prose.push(sentence);
}
return { kind: "entry", name, note, text: prose.join(" "), facts };
}
export function parseLegalParagraph(paragraph: string): LegalBlock {
const text = paragraph.trim();
const lines = text.split("\n");
if (lines.length > 1 && lines.every((line) => LIST_LINE.test(line))) {
return { kind: "list", items: lines.map((line) => LIST_LINE.exec(line)![1].trim()) };
}
const code = CODE_ENTRY.exec(text);
if (code) return toEntry(code[1], null, code[2]);
const labelled = LABEL_ENTRY.exec(text);
if (labelled) return toEntry(labelled[1], labelled[2], labelled[3]);
return { kind: "text", text };
}
/** Holt "Stand: …" aus dem Text, damit es im Seitenkopf statt als loser Satz am Ende steht. */
export function extractRevision(sections: LegalSectionContent[]): { sections: LegalSectionContent[]; revision: string | null } {
let revision: string | null = null;
const cleaned = sections
.map((section) => ({
...section,
paragraphs: section.paragraphs.filter((p) => {
const match = p.includes("\n") ? null : REVISION.exec(p.trim());
if (match && revision === null) revision = match[2].trim();
return !match;
}),
}))
.filter((section) => section.paragraphs.length > 0);
return { sections: cleaned, revision };
}
const UMLAUTS: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
export function sectionAnchors(headings: string[]): string[] {
const seen = new Map<string, number>();
return headings.map((heading, i) => {
const base =
heading
.toLowerCase()
.replace(/^\s*\d+[.)]?\s*/, "")
.replace(/[äöüß]/g, (c) => UMLAUTS[c])
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || `abschnitt-${i + 1}`;
const count = (seen.get(base) ?? 0) + 1;
seen.set(base, count);
return count === 1 ? base : `${base}-${count}`;
});
}
// Nur https/http-Adressen, Domains MIT Pfad (stripe.com/de/privacy) und
// E-Mail-Adressen werden verlinkt. Nackte Domains bleiben Text, andere
// Schemata (javascript:, data:) werden nie zu Links.
const INLINE_LINK =
/(https?:\/\/[^\s<>"]*[^\s<>".,;:)])|((?<![\w@/.])(?:[a-z0-9-]+\.)+[a-z]{2,}\/[^\s<>"]*[^\s<>".,;:)])|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
export function tokenizeInline(text: string): InlineToken[] {
const tokens: InlineToken[] = [];
let last = 0;
for (const match of text.matchAll(INLINE_LINK)) {
if (match.index > last) tokens.push({ kind: "text", value: text.slice(last, match.index) });
const [value, url, domainPath, email] = match;
const href = url ?? (domainPath ? `https://${domainPath}` : `mailto:${email}`);
tokens.push({ kind: "link", value, href });
last = match.index + value.length;
}
if (last < text.length) tokens.push({ kind: "text", value: text.slice(last) });
return tokens;
}
+147 -37
View File
@@ -1,4 +1,8 @@
import nodemailer from "nodemailer"; import nodemailer, { type Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
import { createMs365TokenProvider, type Ms365TokenProvider } from "@/lib/ms365-oauth";
import { readEntraAppCredentials, type EntraAppCredentials } from "@/lib/entra";
export interface MailAttachment { export interface MailAttachment {
filename: string; filename: string;
@@ -20,6 +24,30 @@ interface SendMailInput {
attachments?: MailAttachment[]; attachments?: MailAttachment[];
} }
/**
* Versand ausschliesslich ueber Microsoft 365 mit OAuth 2.0 (SMTP XOAUTH2,
* Client-Credentials-Flow). Als App dient dieselbe App-Registrierung wie fuer
* "Mit Microsoft anmelden" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID,
* ENTRA_CLIENT_SECRET) — eine Unternehmensanwendung statt zwei. Eine
* Anmeldung per Passwort (Basic Auth) gibt es bewusst nicht mehr: kein
* Benutzerpasswort im Cluster, kein Konflikt mit MFA/Sicherheitsstandards,
* nicht betroffen vom Ende der Basic Auth bei Exchange Online.
*
* SMTP_USER ist das Postfach, mit dem gesendet wird; Exchange erlaubt der App
* nur die Postfaecher, die ihr per Add-MailboxPermission freigegeben wurden.
*/
interface SmtpConfig {
user: string;
app: EntraAppCredentials;
}
const MS365_SMTP_HOST = "smtp.office365.com";
/** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */
const MS365_SMTP_PORT = 587;
const TEST_TIMEOUT_MS = 15_000;
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
/** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */ /** Maskiert eine E-Mail-Adresse fuer Log-Ausgaben, z.B. "ma***@example.com". */
function maskEmail(email: string): string { function maskEmail(email: string): string {
const [local, domain] = email.split("@"); const [local, domain] = email.split("@");
@@ -27,45 +55,92 @@ function maskEmail(email: string): string {
return `${local.slice(0, 2)}***@${domain}`; return `${local.slice(0, 2)}***@${domain}`;
} }
let cachedTransport: ReturnType<typeof nodemailer.createTransport> | null = null; /** Namen der Variablen, die fuer den Versand noch fehlen — leer, wenn alles da ist. */
// Nur einmal geprueft (nicht bei jedem sendMail-Aufruf) — macht ein in function missingSmtpSettings(): string[] {
// Produktion fehlendes SMTP-Setup im Server-Log sichtbar, statt dass const missing: string[] = [];
if (!process.env.SMTP_USER?.trim()) missing.push("SMTP_USER");
if (!process.env.ENTRA_TENANT_ID?.trim()) missing.push("ENTRA_TENANT_ID");
if (!process.env.ENTRA_CLIENT_ID?.trim()) missing.push("ENTRA_CLIENT_ID");
if (!process.env.ENTRA_CLIENT_SECRET?.trim()) missing.push("ENTRA_CLIENT_SECRET");
return missing;
}
function readSmtpConfig(): SmtpConfig | null {
const user = process.env.SMTP_USER?.trim() ?? "";
const app = readEntraAppCredentials();
return user && app ? { user, app } : null;
}
function getFromAddress(): string {
return process.env.SMTP_FROM || "Wabenhain <[email protected]>";
}
// Ein Token-Provider pro Prozess: das Token (ca. 60 Minuten gueltig) wird
// ueber alle Mails hinweg wiederverwendet statt je Mail neu geholt.
let tokenProvider: { key: string; provider: Ms365TokenProvider } | null = null;
function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider {
const key = `${app.tenantId}|${app.clientId}|${app.clientSecret}`;
if (!tokenProvider || tokenProvider.key !== key) {
tokenProvider = { key, provider: createMs365TokenProvider(app) };
}
return tokenProvider.provider;
}
function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter {
const options: SMTPTransport.Options = {
host: MS365_SMTP_HOST,
port: MS365_SMTP_PORT,
secure: false,
// Die Verbindung MUSS per STARTTLS verschluesselt werden, bevor das Token
// gesendet wird — ohne requireTLS wuerde nodemailer bei einem Server ohne
// STARTTLS-Angebot (Angriff auf dem Netzweg) im Klartext weitermachen.
requireTLS: true,
auth: {
type: "OAuth2",
user: config.user,
// nodemailer fragt hier nach einem Token; renew=true nach einer
// Ablehnung durch den Server → frisches Token statt des gecachten.
// expires 0: nodemailer fragt bei jeder Anmeldung neu — das Cachen mit
// Ablaufreserve uebernimmt der Token-Provider.
provisionCallback: (_user: string, renew: boolean, callback: (err: Error | null, accessToken: string, expires: number) => void) => {
getTokenProvider(config.app)
.getToken(renew)
.then((token) => callback(null, token, 0), (error: Error) => callback(error, "", 0));
},
},
...extra,
};
return nodemailer.createTransport(options);
}
let cachedTransport: { config: SmtpConfig; transport: SmtpTransporter } | null = null;
// Nur einmal gemeldet (nicht bei jedem sendMail-Aufruf) — macht ein in
// Produktion fehlendes Setup im Server-Log sichtbar, statt dass
// Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden. // Bestellbestaetigungen/Passwort-Resets fortlaufend lautlos verschwinden.
let warnedAboutMissingSmtpInProduction = false; let warnedAboutMissingSmtpInProduction = false;
function getTransport() { function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | null {
if (cachedTransport) return cachedTransport; const config = readSmtpConfig();
if (!config) {
const host = process.env.SMTP_HOST;
const port = Number(process.env.SMTP_PORT ?? 587);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!host || !user || !pass) {
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) { if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
warnedAboutMissingSmtpInProduction = true; warnedAboutMissingSmtpInProduction = true;
console.warn( console.warn(
"SMTP_HOST/SMTP_USER/SMTP_PASSWORD sind in Produktion nicht gesetzt — Bestellbestaetigungen, Passwort-Resets und weitere Transaktions-E-Mails werden nur geloggt, nicht versendet." `[mail] Mailversand nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.`
); );
} }
return null; return null;
} }
if (!cachedTransport) cachedTransport = { config, transport: buildTransport(config) };
cachedTransport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
return cachedTransport; return cachedTransport;
} }
/** /**
* Versendet eine E-Mail ueber den in SMTP_HOST/SMTP_USER/SMTP_PASSWORD * Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die
* konfigurierten Mailserver. Ohne Konfiguration wird die Mail NICHT * Mail NICHT gesendet, sondern nur geloggt — damit lokale Entwicklung und
* gesendet, sondern nur geloggt — analog zum Stripe-Fallback ohne Keys, * Deployments ohne Mail-Setup nicht abstuerzen. Wirft absichtlich keinen
* damit lokale Entwicklung und Deployments ohne Mail-Setup nicht abstuerzen. * Fehler: ein Mail-Ausfall darf eine Bestellung oder Registrierung nicht
* Wirft absichtlich keinen Fehler: ein Mail-Ausfall darf eine Bestellung * verhindern.
* oder Registrierung nicht verhindern.
* *
* Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst * Liefert true, wenn der Mailserver die Nachricht angenommen hat, sonst
* false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit * false (nicht konfiguriert oder Versand fehlgeschlagen). Aktionen mit
@@ -74,19 +149,15 @@ function getTransport() {
* Kundschaft auf eine Mail, die nie kommt. * Kundschaft auf eine Mail, die nie kommt.
*/ */
export async function sendMail(input: SendMailInput): Promise<boolean> { export async function sendMail(input: SendMailInput): Promise<boolean> {
const transport = getTransport(); const active = getTransport();
const from = process.env.SMTP_FROM || "Wabenhain <[email protected]>"; if (!active) {
console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
if (!transport) {
console.warn(
`[mail] SMTP nicht konfiguriert — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`
);
return false; return false;
} }
try { try {
await transport.sendMail({ await active.transport.sendMail({
from, from: getFromAddress(),
to: input.to, to: input.to,
subject: input.subject, subject: input.subject,
html: input.html, html: input.html,
@@ -95,8 +166,47 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
}); });
return true; return true;
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler"; const diagnosis = explainSmtpError(error, { secrets: [active.config.app.clientSecret] });
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen: ${message}`); console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
return false; return false;
} }
} }
interface SmtpTestBase {
host: string;
user: string;
from: string;
}
export type SmtpTestResult =
| ({ ok: true } & SmtpTestBase)
| { ok: false; configured: false; missing: string[] }
| ({ ok: false; configured: true; diagnosis: SmtpDiagnosis } & SmtpTestBase);
/**
* Prueft den Mailversand im laufenden Betrieb: Token, Verbindung, Anmeldung
* (verify) und danach ein echter Versand an `to` — erst der Versand zeigt,
* ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. Baut
* bewusst einen frischen Transport statt des zwischengespeicherten. Das
* Client-Secret erscheint nie im Ergebnis.
*/
export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
const config = readSmtpConfig();
if (!config) return { ok: false, configured: false, missing: missingSmtpSettings() };
const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress() };
const transport = buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
try {
await transport.verify();
await transport.sendMail({
from: base.from,
to,
subject: "Wabenhain: Test des E-Mail-Versands",
text: `Diese Test-Mail bestaetigt, dass der Shop ueber Microsoft 365 (OAuth 2.0) als ${config.user} mit dem Absender ${base.from} senden kann.`,
});
return { ok: true, ...base };
} catch (error: unknown) {
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) };
} finally {
transport.close();
}
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { createMs365TokenProvider, Ms365TokenError } from "./ms365-oauth";
const CONFIG = { tenantId: "wabenhain.onmicrosoft.com", clientId: "11111111-2222-3333-4444-555555555555", clientSecret: "s3cr3t~value" };
function tokenResponse(token: string, expiresIn = 3600): Response {
return new Response(JSON.stringify({ token_type: "Bearer", expires_in: expiresIn, access_token: token }), { status: 200 });
}
describe("createMs365TokenProvider", () => {
it("requests a client-credentials token for Exchange Online", async () => {
const fetchImpl = vi.fn(async () => tokenResponse("tok-1"));
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
await expect(provider.getToken()).resolves.toBe("tok-1");
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("https://login.microsoftonline.com/wabenhain.onmicrosoft.com/oauth2/v2.0/token");
expect(init.method).toBe("POST");
const body = new URLSearchParams(String(init.body));
expect(body.get("grant_type")).toBe("client_credentials");
expect(body.get("scope")).toBe("https://outlook.office365.com/.default");
expect(body.get("client_id")).toBe(CONFIG.clientId);
expect(body.get("client_secret")).toBe(CONFIG.clientSecret);
});
it("reuses the cached token until shortly before it expires", async () => {
let now = 1_000_000;
const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1", 3600)).mockResolvedValueOnce(tokenResponse("tok-2", 3600));
const provider = createMs365TokenProvider(CONFIG, { fetchImpl, now: () => now });
expect(await provider.getToken()).toBe("tok-1");
now += 50 * 60 * 1000; // 50 Minuten: noch gueltig
expect(await provider.getToken()).toBe("tok-1");
now += 6 * 60 * 1000; // 56 Minuten: innerhalb der 5-Minuten-Reserve
expect(await provider.getToken()).toBe("tok-2");
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("fetches a fresh token when a renewal is forced", async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(tokenResponse("tok-1")).mockResolvedValueOnce(tokenResponse("tok-2"));
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
await provider.getToken();
expect(await provider.getToken(true)).toBe("tok-2");
});
it("shares one request between concurrent callers", async () => {
const fetchImpl = vi.fn(async () => tokenResponse("tok-1"));
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
const tokens = await Promise.all([provider.getToken(), provider.getToken(), provider.getToken()]);
expect(tokens).toEqual(["tok-1", "tok-1", "tok-1"]);
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("reports the AADSTS code and never the client secret", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
error: "invalid_client",
error_description: `AADSTS7000215: Invalid client secret provided. Ensure the secret being sent is the client secret value, not the client secret ID, for a secret added to app '${CONFIG.clientId}'. Secret: ${CONFIG.clientSecret}`,
}),
{ status: 401 }
)
);
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
const error = await provider.getToken().catch((e: unknown) => e);
expect(error).toBeInstanceOf(Ms365TokenError);
expect((error as Ms365TokenError).aadCode).toBe("7000215");
expect((error as Error).message).not.toContain(CONFIG.clientSecret);
});
it("does not cache a failed request", async () => {
const fetchImpl = vi.fn().mockRejectedValueOnce(new Error("fetch failed")).mockResolvedValueOnce(tokenResponse("tok-1"));
const provider = createMs365TokenProvider(CONFIG, { fetchImpl });
await expect(provider.getToken()).rejects.toBeInstanceOf(Ms365TokenError);
await expect(provider.getToken()).resolves.toBe("tok-1");
});
it("rejects a tenant id that could change the request URL", () => {
expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "evil.com/x?" })).toThrow(Ms365TokenError);
expect(() => createMs365TokenProvider({ ...CONFIG, tenantId: "../common" })).toThrow(Ms365TokenError);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* OAuth 2.0 fuer den Mailversand ueber Microsoft 365 (Exchange Online SMTP
* mit XOAUTH2). Client-Credentials-Flow: der Shop meldet sich als eigene
* App-Registrierung in Entra ID an — kein Benutzerpasswort, kein MFA-Konflikt,
* unabhaengig vom Ende der Basic-Auth fuer SMTP. Die App darf nur mit dem
* Postfach senden, fuer das Exchange ihr ausdruecklich Zugriff gibt
* (Berechtigung SMTP.SendAsApp + Add-MailboxPermission, siehe README). Die
* Zugangsdaten stammen aus der SSO-App-Registrierung (lib/entra.ts).
*/
export interface Ms365OAuthConfig {
tenantId: string;
clientId: string;
clientSecret: string;
}
export class Ms365TokenError extends Error {
/** Fehlernummer von Entra ID ("AADSTS7000215" → "7000215"), falls vorhanden. */
readonly aadCode: string | null;
constructor(message: string, aadCode: string | null = null) {
super(message);
this.name = "Ms365TokenError";
this.aadCode = aadCode;
}
}
export interface Ms365TokenProvider {
/** Liefert ein gueltiges Zugriffstoken; `forceRefresh` holt ein neues (nach einer Ablehnung durch den Mailserver). */
getToken(forceRefresh?: boolean): Promise<string>;
}
interface ProviderOptions {
fetchImpl?: typeof fetch;
now?: () => number;
}
const SCOPE = "https://outlook.office365.com/.default";
/** Token 5 Minuten vor Ablauf erneuern — ein laufender Versand soll nie mit einem gerade abgelaufenen Token starten. */
const EXPIRY_MARGIN_MS = 5 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 15_000;
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i;
function redact(text: string, secret: string): string {
return secret ? text.split(secret).join("***") : text;
}
export function createMs365TokenProvider(config: Ms365OAuthConfig, options: ProviderOptions = {}): Ms365TokenProvider {
// Die Tenant-ID landet im URL-Pfad: nur GUID oder Domainname, sonst
// koennte ein manipulierter Wert die Anfrage an einen anderen Endpunkt lenken.
if (!GUID.test(config.tenantId) && !DOMAIN.test(config.tenantId)) {
throw new Ms365TokenError("ENTRA_TENANT_ID ist weder eine Tenant-GUID noch ein Domainname.");
}
const fetchImpl = options.fetchImpl ?? fetch;
const now = options.now ?? Date.now;
const url = `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
let cached: { token: string; refreshAt: number } | null = null;
let inFlight: Promise<string> | null = null;
async function requestToken(): Promise<string> {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: config.clientId,
client_secret: config.clientSecret,
scope: SCOPE,
});
let response: Response;
try {
response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
throw new Ms365TokenError(`Entra ID nicht erreichbar: ${redact(message, config.clientSecret)}`);
}
const payload: unknown = await response.json().catch(() => null);
const data = (payload && typeof payload === "object" ? payload : {}) as Record<string, unknown>;
if (!response.ok || typeof data.access_token !== "string") {
const description = typeof data.error_description === "string" ? data.error_description : `HTTP ${response.status}`;
const aadCode = /AADSTS(\d+)/.exec(description)?.[1] ?? null;
// Nur die erste Zeile: Entra haengt Trace- und Korrelations-IDs an.
throw new Ms365TokenError(`Token-Abruf abgelehnt: ${redact(description.split(/\r?\n/)[0], config.clientSecret)}`, aadCode);
}
const expiresInMs = (typeof data.expires_in === "number" ? data.expires_in : 3600) * 1000;
cached = { token: data.access_token, refreshAt: now() + expiresInMs - EXPIRY_MARGIN_MS };
return data.access_token;
}
return {
async getToken(forceRefresh = false): Promise<string> {
if (!forceRefresh && cached && now() < cached.refreshAt) return cached.token;
// Parallele Mails teilen sich einen Abruf, statt Entra gleichzeitig mehrfach zu fragen.
if (!inFlight) {
inFlight = requestToken().finally(() => {
inFlight = null;
});
}
return inFlight;
},
};
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { explainSmtpError } from "./smtp-diagnose";
import { Ms365TokenError } from "./ms365-oauth";
function smtpError(message: string, extra: Record<string, unknown> = {}): Error {
return Object.assign(new Error(message), extra);
}
describe("explainSmtpError", () => {
it("explains a client secret ID used instead of its value", () => {
const result = explainSmtpError(new Ms365TokenError("Token-Abruf abgelehnt: AADSTS7000215: Invalid client secret provided.", "7000215"));
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("ENTRA_CLIENT_SECRET");
expect(result.hint).toContain("Wert");
});
it("explains an expired client secret and that SSO is affected too", () => {
const result = explainSmtpError(new Ms365TokenError("AADSTS7000222: The provided client secret keys are expired.", "7000222"));
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("abgelaufen");
expect(result.hint).toContain("Mit Microsoft anmelden");
});
it("explains an unknown app or tenant", () => {
expect(explainSmtpError(new Ms365TokenError("AADSTS700016: Application not found", "700016")).hint).toContain("ENTRA_CLIENT_ID");
expect(explainSmtpError(new Ms365TokenError("AADSTS90002: Tenant not found", "90002")).hint).toContain("ENTRA_TENANT_ID");
});
it("falls back to a general token hint for other AADSTS codes", () => {
const result = explainSmtpError(new Ms365TokenError("AADSTS1234: something", "1234"));
expect(result.cause).toBe("oauth-token");
expect(result.hint).toContain("ENTRA_TENANT_ID");
});
it("maps a rejected XOAUTH2 login to missing mailbox access", () => {
const result = explainSmtpError(smtpError("Invalid login: 535 5.7.3 Authentication unsuccessful", { code: "EAUTH" }));
expect(result.cause).toBe("oauth-mailbox-access");
expect(result.hint).toContain("SMTP.SendAsApp");
expect(result.hint).toContain("Add-MailboxPermission");
});
it("recognises SMTP AUTH disabled for the tenant or mailbox", () => {
expect(explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Tenant.")).cause).toBe(
"smtp-auth-disabled"
);
expect(explainSmtpError(smtpError("535 5.7.139 Authentication unsuccessful, SmtpClientAuthentication is disabled for the Mailbox.")).cause).toBe(
"smtp-auth-disabled"
);
});
it("recognises a sender address the mailbox may not use", () => {
expect(
explainSmtpError(smtpError("Message failed: 554 5.2.252 SendAsDenied; [email protected] not allowed to send as [email protected]")).cause
).toBe("send-as-denied");
expect(explainSmtpError(smtpError("550 5.7.60 SMTP; Client does not have permissions to send as this sender")).cause).toBe("send-as-denied");
});
it("recognises network problems", () => {
expect(explainSmtpError(smtpError("connect ETIMEDOUT 52.97.1.1:587", { code: "ETIMEDOUT" })).cause).toBe("network");
expect(explainSmtpError(smtpError("getaddrinfo ENOTFOUND smtp.office365.com", { code: "EDNS" })).cause).toBe("network");
});
it("falls back to unknown and never throws on odd input", () => {
expect(explainSmtpError("kaputt").cause).toBe("unknown");
expect(explainSmtpError(null).cause).toBe("unknown");
});
it("redacts secrets from the echoed server message", () => {
const result = explainSmtpError(smtpError("AUTH XOAUTH2 eyJtoken failed secret-xyz"), { secrets: ["eyJtoken", "secret-xyz"] });
expect(result.detail).not.toContain("eyJtoken");
expect(result.detail).not.toContain("secret-xyz");
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Uebersetzt Fehler beim Mailversand ueber Microsoft 365 (OAuth 2.0, siehe
* lib/mail.ts) in eine konkrete Ursache mit deutscher Handlungsanweisung.
* Der Versand selbst verschluckt Fehler bewusst — ohne diese Einordnung sieht
* man nur "keine Mails kommen an".
*/
import { Ms365TokenError } from "@/lib/ms365-oauth";
export type SmtpFailureCause =
| "oauth-token"
| "oauth-mailbox-access"
| "smtp-auth-disabled"
| "send-as-denied"
| "network"
| "tls"
| "unknown";
export interface SmtpDiagnosis {
cause: SmtpFailureCause;
hint: string;
/** Originalmeldung des Servers, gekuerzt und ohne Geheimnisse. */
detail: string;
}
const HINTS: Record<SmtpFailureCause, string> = {
"oauth-token":
"Microsoft Entra ID gibt kein Zugriffstoken aus. Prüfe ENTRA_TENANT_ID, ENTRA_CLIENT_ID und ENTRA_CLIENT_SECRET — dieselben Werte wie für „Mit Microsoft anmelden“.",
"oauth-mailbox-access":
"Das Token ist gültig, aber Exchange lässt die App nicht mit dem Postfach aus SMTP_USER senden. Prüfe in dieser Reihenfolge: (1) API-Berechtigung „Office 365 Exchange Online → SMTP.SendAsApp“ (Anwendung) mit Administratorzustimmung an der SSO-App-Registrierung, (2) New-ServicePrincipal -AppId <ENTRA_CLIENT_ID> -ObjectId <Objekt-ID der Unternehmensanwendung> in Exchange Online PowerShell, (3) Add-MailboxPermission -Identity <SMTP_USER> -User <Objekt-ID> -AccessRights FullAccess. Nach Änderungen bis zu 30 Minuten warten.",
"smtp-auth-disabled":
"„Authentifiziertes SMTP“ ist für den Tenant oder das Postfach ausgeschaltet — auch OAuth 2.0 läuft darüber. Exchange Online PowerShell: Set-CASMailbox -Identity <SMTP_USER> -SmtpClientAuthenticationDisabled $false. Die Postfach-Einstellung hat Vorrang vor der des Tenants; Änderungen können bis zu einer Stunde dauern.",
"send-as-denied":
"Die App darf nicht mit der Absenderadresse (SMTP_FROM) senden. SMTP_FROM muss die Adresse des Postfachs aus SMTP_USER sein, oder das Postfach braucht die Berechtigung „Senden als“ für diese Adresse. Für einen Alias: Set-OrganizationConfig -SendFromAliasEnabled $true.",
network:
"smtp.office365.com ist nicht erreichbar. Prüfe, ob der Cluster ausgehende Verbindungen auf Port 587 und zu login.microsoftonline.com erlaubt (Firewall, NetworkPolicy).",
tls: "Die verschlüsselte Verbindung zu smtp.office365.com ist fehlgeschlagen (STARTTLS auf Port 587). Prüfe Proxy oder TLS-Inspektion auf dem Netzweg.",
unknown: "Unbekannter Fehler. Die Originalmeldung des Mailservers steht unten.",
};
/** Konkrete Anweisung zu den haeufigsten Entra-Fehlernummern beim Token-Abruf. */
const AAD_HINTS: Record<string, string> = {
"7000215":
"Das Client-Secret ist falsch. In ENTRA_CLIENT_SECRET gehört der „Wert“ des geheimen Clientschlüssels, nicht die „Geheimnis-ID“. Der Wert wird nur direkt nach dem Anlegen angezeigt — sonst einen neuen Schlüssel erstellen.",
"7000222":
"Das Client-Secret ist abgelaufen — das betrifft auch „Mit Microsoft anmelden“. Entra ID → App-Registrierungen → Zertifikate & Geheimnisse → neuen geheimen Clientschlüssel anlegen, ENTRA_CLIENT_SECRET im Kubernetes-Secret ersetzen, Pod neu starten. Ablaufdatum notieren.",
"700016":
"Die App-Registrierung wurde in diesem Tenant nicht gefunden. Prüfe ENTRA_CLIENT_ID (Anwendungs-ID/Client-ID) und ob ENTRA_TENANT_ID zum selben Tenant gehört.",
"90002": "Der Tenant existiert nicht. Prüfe ENTRA_TENANT_ID (Verzeichnis-ID/Mandanten-ID).",
"900023": "Die Tenant-Angabe ist ungültig. Prüfe ENTRA_TENANT_ID (Verzeichnis-ID/Mandanten-ID).",
};
const MAX_DETAIL_LENGTH = 300;
function readError(error: unknown): { message: string; code: string } {
if (error instanceof Error) {
const code = (error as Error & { code?: unknown }).code;
return { message: error.message, code: typeof code === "string" ? code : "" };
}
return { message: typeof error === "string" ? error : "", code: "" };
}
function classify(error: unknown, message: string, code: string): SmtpFailureCause {
if (error instanceof Ms365TokenError) return "oauth-token";
const text = message.toLowerCase();
if (text.includes("smtpclientauthentication is disabled")) return "smtp-auth-disabled";
if (text.includes("sendasdenied") || text.includes("5.2.252") || text.includes("5.7.60")) return "send-as-denied";
// Mit OAuth gibt es kein Passwort, das falsch sein koennte: ein abgelehntes
// XOAUTH2-Token heisst fast immer fehlender Postfachzugriff der App.
if (code === "EAUTH" || text.includes("535") || text.includes("authentication unsuccessful")) return "oauth-mailbox-access";
if (["ETIMEDOUT", "ECONNREFUSED", "ECONNECTION", "EDNS", "ENOTFOUND", "ESOCKET", "ECONNRESET"].includes(code) || /etimedout|econnrefused|enotfound/.test(text)) {
return "network";
}
if (code === "ETLS" || text.includes("certificate") || text.includes("ssl") || text.includes("tls")) return "tls";
return "unknown";
}
interface ExplainOptions {
/** Werte, die nie in der Ausgabe erscheinen duerfen (Client-Secret, Token). */
secrets?: (string | undefined)[];
}
export function explainSmtpError(error: unknown, options: ExplainOptions = {}): SmtpDiagnosis {
const { message, code } = readError(error);
const cause = classify(error, message, code);
const aadHint = error instanceof Ms365TokenError && error.aadCode ? AAD_HINTS[error.aadCode] : undefined;
const redacted = (options.secrets ?? []).reduce<string>((text, secret) => (secret ? text.split(secret).join("***") : text), message);
return { cause, hint: aadHint ?? HINTS[cause], detail: redacted.slice(0, MAX_DETAIL_LENGTH) };
}
+11
View File
@@ -29,6 +29,17 @@ export const returnRequestSchema = z.object({
}); });
export type ReturnRequestInput = z.infer<typeof returnRequestSchema>; export type ReturnRequestInput = z.infer<typeof returnRequestSchema>;
// Elektronische Widerrufsfunktion (§ 356a Abs. 2 BGB): Pflicht sind NUR Name,
// Vertragsidentifikation und E-Mail — jedes weitere Pflichtfeld waere eine
// unzulaessige Huerde. Die Nachricht bleibt daher optional.
export const withdrawalSchema = z.object({
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
orderNumber: z.string().trim().min(1, "Bitte gib deine Bestellnummer an.").max(40),
email: emailSchema,
message: z.string().trim().max(2000).optional().transform((value) => value || null),
});
export type WithdrawalInput = z.infer<typeof withdrawalSchema>;
export const businessInquirySchema = z.object({ export const businessInquirySchema = z.object({
companyName: z.string().trim().min(2, "Bitte gib den Firmennamen an.").max(200), companyName: z.string().trim().min(2, "Bitte gib den Firmennamen an.").max(200),
contactPerson: z.string().trim().min(2, "Bitte gib einen Ansprechpartner an.").max(100), contactPerson: z.string().trim().min(2, "Bitte gib einen Ansprechpartner an.").max(100),
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { formatReceivedAt, withdrawalDeclarationLines, withdrawalReceiptEmailContent } from "./withdrawal";
import { escapeHtml } from "./escape-html";
const input = {
name: "Erika Mustermann",
orderNumber: "WH-2026-0042",
email: "[email protected]",
message: null,
};
// 26.09.2026 12:03:07 UTC = 14:03:07 in Berlin (Sommerzeit)
const receivedAt = new Date(Date.UTC(2026, 8, 26, 12, 3, 7));
describe("formatReceivedAt", () => {
it("formats in German local time with seconds", () => {
expect(formatReceivedAt(receivedAt, "de")).toBe("26.09.2026, 14:03:07 Uhr (MESZ)");
});
it("uses winter time in winter", () => {
expect(formatReceivedAt(new Date(Date.UTC(2026, 0, 5, 9, 0, 0)), "de")).toBe("05.01.2026, 10:00:00 Uhr (MEZ)");
});
it("formats in English with the German time zone", () => {
expect(formatReceivedAt(receivedAt, "en")).toBe("26 September 2026, 14:03:07 (CEST, German time)");
});
});
describe("withdrawalDeclarationLines", () => {
it("states the withdrawal and the identifying data", () => {
expect(withdrawalDeclarationLines(input, "de")).toEqual([
"Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.",
"Name: Erika Mustermann",
"E-Mail: [email protected]",
]);
});
it("adds an optional message", () => {
expect(withdrawalDeclarationLines({ ...input, message: "Falsche Sorte" }, "en")).toEqual([
"I hereby withdraw from the contract for order WH-2026-0042.",
"Name: Erika Mustermann",
"Email: [email protected]",
"Message: Falsche Sorte",
]);
});
});
describe("escapeHtml", () => {
it("neutralises markup", () => {
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe("&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;");
});
});
describe("withdrawalReceiptEmailContent", () => {
it("contains the declaration and the time of receipt in text and HTML", () => {
const mail = withdrawalReceiptEmailContent(input, receivedAt, "de");
expect(mail.subject).toBe("Eingangsbestätigung deines Widerrufs zu WH-2026-0042");
expect(mail.text).toContain("Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.");
expect(mail.text).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
expect(mail.bodyHtml).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
});
it("escapes user input in the HTML body and strips line breaks from the subject", () => {
const mail = withdrawalReceiptEmailContent(
{ ...input, name: `<img src=x onerror=alert(1)>`, orderNumber: "A1\r\nBcc: [email protected]" },
receivedAt,
"de"
);
expect(mail.bodyHtml).not.toContain("<img");
expect(mail.bodyHtml).toContain("&lt;img src=x onerror=alert(1)&gt;");
expect(mail.subject).not.toMatch(/[\r\n]/);
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { Locale } from "@/i18n/config";
import { escapeHtml } from "@/lib/escape-html";
/**
* Elektronische Widerrufsfunktion (§ 356a BGB): Wortlaut der Erklaerung und
* der Eingangsbestaetigung. Bewusst an EINER Stelle statt in email-strings.ts:
* derselbe Wortlaut erscheint in der Mail an die Kundschaft, auf der
* Bestaetigungsseite und im Admin — die Eingangsbestaetigung muss genau das
* wiedergeben, was erklaert wurde (§ 356a Abs. 4 BGB).
*/
export interface WithdrawalDeclarationInput {
name: string;
orderNumber: string;
email: string;
message: string | null;
}
export interface WithdrawalReceiptEmail {
subject: string;
preheader: string;
/** Inhalt fuer den gemeinsamen Mail-Rahmen (emailLayout), bereits maskiert. preheader maskiert emailLayout selbst. */
bodyHtml: string;
text: string;
}
const TIME_ZONE = "Europe/Berlin";
const STRINGS = {
de: {
declaration: (orderNumber: string) => `Hiermit widerrufe ich den Vertrag über die Bestellung ${orderNumber}.`,
name: "Name",
email: "E-Mail",
message: "Nachricht",
subject: (orderNumber: string) => `Eingangsbestätigung deines Widerrufs zu ${orderNumber}`,
heading: "Dein Widerruf ist bei uns eingegangen",
intro: "Hiermit bestätigen wir den Eingang deiner Widerrufserklärung, die du über unsere Widerrufsfunktion abgegeben hast. Sie lautet:",
receivedAt: "Eingegangen am",
next: "Bitte sende uns die Ware innerhalb von 14 Tagen zurück, sofern du sie bereits erhalten hast. Sobald die Ware bei uns ist oder du uns die Rücksendung nachgewiesen hast, erstatten wir dir den Kaufpreis. Diese Bestätigung dokumentiert den Eingang deiner Erklärung.",
},
en: {
declaration: (orderNumber: string) => `I hereby withdraw from the contract for order ${orderNumber}.`,
name: "Name",
email: "Email",
message: "Message",
subject: (orderNumber: string) => `Confirmation of receipt of your withdrawal for ${orderNumber}`,
heading: "We have received your withdrawal",
intro: "We hereby confirm receipt of the notice of withdrawal you submitted via our withdrawal function. It reads:",
receivedAt: "Received on",
next: "If you have already received the goods, please send them back to us within 14 days. We will refund the purchase price once the goods have arrived or you have shown proof of return. This confirmation documents the receipt of your notice.",
},
} as const;
function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
return parts.find((p) => p.type === type)?.value ?? "";
}
/** Zugangszeitpunkt in deutscher Ortszeit, sekundengenau und mit Zeitzone — er ist Beweismittel. */
export function formatReceivedAt(date: Date, locale: Locale): string {
const time = new Intl.DateTimeFormat("de-DE", {
timeZone: TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).format(date);
if (locale === "de") {
const day = new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, day: "2-digit", month: "2-digit", year: "numeric" }).format(date);
const zone = part(new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, timeZoneName: "short" }).formatToParts(date), "timeZoneName");
return `${day}, ${time} Uhr (${zone})`;
}
const day = new Intl.DateTimeFormat("en-GB", { timeZone: TIME_ZONE, day: "numeric", month: "long", year: "numeric" }).format(date);
const zone = part(new Intl.DateTimeFormat("en-GB", { timeZone: TIME_ZONE, timeZoneName: "short" }).formatToParts(date), "timeZoneName");
return `${day}, ${time} (${zone}, German time)`;
}
export function withdrawalDeclarationLines(input: WithdrawalDeclarationInput, locale: Locale): string[] {
const s = STRINGS[locale];
return [
s.declaration(input.orderNumber),
`${s.name}: ${input.name}`,
`${s.email}: ${input.email}`,
...(input.message ? [`${s.message}: ${input.message}`] : []),
];
}
/** Zeilenumbrueche im Betreff wuerden sonst als weitere Mail-Header gelesen (Header-Injection). */
function singleLine(value: string): string {
return value.replace(/[\r\n]+/g, " ");
}
export function withdrawalReceiptEmailContent(
input: WithdrawalDeclarationInput,
receivedAt: Date,
locale: Locale
): WithdrawalReceiptEmail {
const s = STRINGS[locale];
const lines = withdrawalDeclarationLines(input, locale);
const when = formatReceivedAt(receivedAt, locale);
const bodyHtml = `
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 16px;line-height:1.5;">${s.intro}</p>
<div style="margin:0 0 16px;padding:16px;background-color:#F4EFE5;line-height:1.6;">
${lines.map((line) => escapeHtml(line)).join("<br>")}
</div>
<p style="margin:0 0 16px;line-height:1.5;"><strong>${s.receivedAt}:</strong> ${escapeHtml(when)}</p>
<p style="margin:0;line-height:1.5;">${s.next}</p>
`;
const text = [s.heading, "", s.intro, "", ...lines, "", `${s.receivedAt}: ${when}`, "", s.next].join("\n");
return {
subject: singleLine(s.subject(input.orderNumber)),
preheader: singleLine(`${s.receivedAt}: ${when}`),
bodyHtml,
text,
};
}
+40 -5
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
import { RECENTLY_VIEWED_KEY, hasOptionalConsent } from "@/lib/consent";
const MAX_RECENTLY_VIEWED = 6; const MAX_RECENTLY_VIEWED = 6;
@@ -11,9 +12,43 @@ interface RecentlyViewedState {
} }
/** /**
* Rein lokal im Browser (localStorage) gespeicherte Liste zuletzt * Speichert nur mit Einwilligung ("Alle akzeptieren" im Cookie-Banner): die
* angesehener Produkt-IDs, neuestes zuerst — kein Tracking-Cookie, kein * Liste ist fuer den Einkauf nicht unbedingt erforderlich, das Ablegen auf
* Server-seitiges Nachverfolgen einzelner Besucher:innen. * dem Endgeraet braucht daher nach § 25 Abs. 1 TDDDG eine Einwilligung.
* Ohne sie lebt die Liste nur im Arbeitsspeicher des geoeffneten Tabs und
* verschwindet beim Neuladen. Die Pruefung sitzt hier in der Speicherschicht,
* damit kein Aufrufer sie vergessen kann.
*/
const consentGatedStorage: StateStorage = {
getItem: (name) => {
if (!hasOptionalConsent()) return null;
try {
return window.localStorage.getItem(name);
} catch {
return null;
}
},
setItem: (name, value) => {
if (!hasOptionalConsent()) return;
try {
window.localStorage.setItem(name, value);
} catch {
// Voller oder gesperrter Speicher: Liste bleibt nur im Arbeitsspeicher.
}
},
removeItem: (name) => {
try {
window.localStorage.removeItem(name);
} catch {
// Nichts zu entfernen.
}
},
};
/**
* Rein lokal im Browser gespeicherte Liste zuletzt angesehener Produkt-IDs,
* neuestes zuerst — kein Tracking-Cookie, kein serverseitiges Nachverfolgen
* einzelner Besucher:innen.
*/ */
export const useRecentlyViewedStore = create<RecentlyViewedState>()( export const useRecentlyViewedStore = create<RecentlyViewedState>()(
persist( persist(
@@ -27,6 +62,6 @@ export const useRecentlyViewedStore = create<RecentlyViewedState>()(
), ),
})), })),
}), }),
{ name: "wabenhain-recently-viewed" } { name: RECENTLY_VIEWED_KEY, storage: createJSONStorage(() => consentGatedStorage) }
) )
); );