Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 de82ad0233 feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each
area stands on its own: Firma & Versand, Marke, Startseite, Seiten,
Social Media, Rechtstexte and System & Tests. Tabs are plain links
(?reiter=...), bookmarkable and usable without JavaScript; saving
returns to the same tab.

System & Tests brings everything that checks the running shop into one
place:
- integration overview (Stripe, Stripe webhook, Microsoft 365 mail,
  Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only
  whether each is set up and the names of missing variables, never a
  value; warns when Stripe runs on test keys or mixes test/live
- the email test, now with the IPv4 network checks for
  smtp.office365.com:587 and login.microsoftonline.com:443

/admin/inhalte and /admin/e-mail-test redirect to the new page; the
separate "E-Mail-Test" menu entry is gone. The tab parameter and the
saved group are validated against a fixed list before they are used in
a redirect. Docs point to the new location and cover the ENETUNREACH
(IPv6) case.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:07:34 +02:00
Nick SzaunigandClaude Opus 5.5 b5311fae83 fix(mail): connect to Microsoft 365 over IPv4 and check network paths
The admin email test failed with "connect ENETUNREACH 2603:…:587".
nodemailer resolves smtp.office365.com to IPv4 and IPv6 addresses and
picks one at random; pods with an IPv6 interface but no IPv6 route to
the internet then fail depending on the draw, and the error shown is
always that of the last (IPv6) fallback.

- The shop now resolves smtp.office365.com via IPv4 only and connects
  to that address; the TLS certificate is still verified against
  smtp.office365.com (tls.servername), STARTTLS stays mandatory.
- A transport is built per send so a rotating Microsoft address never
  sticks.
- The email test first probes smtp.office365.com:587 and
  login.microsoftonline.com:443 over IPv4 and reports each result, so a
  blocked port (hoster firewall, NetworkPolicy) is distinguishable from
  a configuration error.

Verified against the real Microsoft endpoints: both probes succeed,
the connection is made via IPv4, TLS validates and the auth stage is
reached.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 22:41:49 +02:00
19 changed files with 731 additions and 318 deletions

No files matched your search

+2 -2
View File
@@ -262,7 +262,7 @@ Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die K
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.
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.
Ob der Versand funktioniert, zeigt **Admin → Einstellungen → System & Tests**: 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):
@@ -278,7 +278,7 @@ Ob der Versand funktioniert, zeigt **Admin → E-Mail-Test**: Token, Anmeldung,
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**.
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 → Einstellungen → System & Tests**.
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.
+6 -5
View File
@@ -377,9 +377,9 @@ Der Reihe nach:
Jeder fehlende Pflichtwert erscheint als Blocker mit Begründung.
2. **Shop erreichbar:** `https://wabenhain.com` öffnen.
3. **Admin-Login:** `https://wabenhain.com/konto` mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` anmelden → Zwei-Faktor-Authentifizierung einrichten → Admin-Bereich öffnet sich.
4. **Mailversand:** **Admin → E-Mail-Test** → „Test-Mail senden“. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)).
4. **Mailversand und Anbindungen:** **Admin → Einstellungen → System & Tests**. Oben steht, welche Dienste eingerichtet sind (fehlende Werte werden benannt). Darunter „Test-Mail senden“: zuerst prüft der Test, ob `smtp.office365.com:587` und `login.microsoftonline.com:443` per IPv4 erreichbar sind, dann den Versand. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)).
5. **Microsoft-Anmeldung:** abmelden, auf `/konto` „Mit Microsoft anmelden“ wählen.
6. **Firmendaten:** **Admin → Inhalte → Firmendaten** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung.
6. **Firmendaten:** **Admin → Einstellungen → Firma & Versand** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung.
7. **Bezahlung:** eine kleine echte Bestellung aufgeben, bezahlen, im Admin prüfen: Status „Bezahlt“, Rechnung vorhanden, Bestätigungsmail angekommen. Danach im Admin erstatten.
8. **Widerruf:** im Footer „Vertrag widerrufen“ testen → Eingangsbestätigung kommt an, Eintrag erscheint unter **Admin → Widerrufe**.
@@ -395,13 +395,13 @@ Der Reihe nach:
| **DHL-/Google-/Turnstile-Schlüssel** | je nach Anbieter | Funktion ruht | Beim Anbieter erneuern, Secret ersetzen |
| **Postfach-Freigabe** | bei Umbenennung oder neuem Absender-Postfach | Mailversand scheitert („Exchange lässt die App nicht …“) | Abschnitt 4.2 für das neue Postfach wiederholen, `SMTP_USER` und `SMTP_FROM` anpassen |
Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → E-Mail-Test**.
Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → Einstellungen → System & Tests**.
---
## 11. Fehlerbehebung
### Mailversand (Anzeige in **Admin → E-Mail-Test**)
### Mailversand (Anzeige in **Admin → Einstellungen → System & Tests**)
| Meldung / Ursache | Lösung |
|---|---|
@@ -413,7 +413,8 @@ Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart depl
| „Exchange lässt die App nicht mit dem Postfach senden“ (`535 5.7.3`) | (1) `SMTP.SendAsApp` erteilt? (3.3) (2) `New-ServicePrincipal` mit der Objekt-ID der **Unternehmensanwendung**? (3.4/4.2) (3) `Add-MailboxPermission` für genau `SMTP_USER`? (4) 30 Minuten gewartet? |
| „Authentifiziertes SMTP ausgeschaltet“ (`SmtpClientAuthentication is disabled`) | `Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false` |
| „SendAsDenied“ | `SMTP_FROM` ist nicht die Adresse aus `SMTP_USER` → angleichen |
| „nicht erreichbar“ (Netzwerk) | Cluster muss ausgehend `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall, NetworkPolicy) |
| „nicht erreichbar“ (Netzwerk) | Die Netzwerkprüfung im Test zeigt, welcher Weg fehlt. Der Cluster muss ausgehend per IPv4 `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall des Hosters, NetworkPolicy). Manche Hoster sperren Mail-Ports für neue Server — dort die Freigabe von Port 587 beantragen. |
| `ENETUNREACH 2603:…` (IPv6-Adresse) | Stammte aus älteren Versionen, die zufällig eine IPv6-Adresse wählten; der Shop verbindet sich inzwischen nur per IPv4. Tritt es trotzdem auf: aktuelles Image ausrollen. |
Ausführliche Fehler stehen außerdem im Pod-Log:
+4 -3
View File
@@ -8,6 +8,7 @@ import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs";
const COMPANY_EMAIL_KEY = "company.email";
@@ -16,7 +17,7 @@ const COMPANY_EMAIL_KEY = "company.email";
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
* "en::{key}" (siehe admin/inhalte/page.tsx).
* "en::{key}" (siehe admin/einstellungen/content-form.tsx).
*/
export async function updateSiteContentAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
@@ -59,7 +60,7 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
// Impressum ein Kontaktweg, der ins Leere fuehrt.
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
return fail(
"/admin/inhalte",
settingsPathForGroup(group),
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
);
}
@@ -85,5 +86,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
});
revalidatePath("/", "layout");
return succeed("/admin/inhalte", "Inhalte gespeichert.");
return succeed(settingsPathForGroup(group), "Einstellungen gespeichert.");
}
+5 -15
View File
@@ -1,17 +1,7 @@
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";
import { redirect } from "next/navigation";
import { SETTINGS_PATH } from "@/lib/settings-tabs";
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>
);
/** Der E-Mail-Test liegt jetzt in Admin → Einstellungen → System & Tests. */
export default function AdminEmailTestRedirect(): never {
redirect(`${SETTINGS_PATH}?reiter=system`);
}
@@ -0,0 +1,199 @@
import { prisma } from "@/lib/prisma";
import { AdminImageField } from "@/components/admin-image-field";
import { AdminVideoField } from "@/components/admin-video-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
import { Button } from "@/components/ui";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1,
textarea: 3,
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
// nur vorhanden, damit dieses Record vollstaendig bleibt.
select: 0,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
// weiter vollstaendig ist.
image: 0,
video: 0,
};
export type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
export async function loadCurrentValues(): Promise<CurrentValues> {
const rows = await prisma.siteContent.findMany();
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
}
/** Bild- und Video-Felder — auf jedem Reiter vor den Textfeldern (fieldsForGroup). */
function isImageOrVideoField(def: ContentFieldDef): boolean {
return def.type === "image" || def.type === "video";
}
function FieldEditor({
def,
row,
locale,
t,
}: {
def: ContentFieldDef;
row: { valueDe: string; valueEn: string | null } | undefined;
locale: Locale;
t: Dictionary;
}) {
const rows = ROWS_BY_TYPE[def.type];
const label = locale === "de" ? def.labelDe : def.labelEn;
if (!def.localized) {
if (def.type === "image") {
return <AdminImageField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "video") {
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "select") {
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
// darstellen als die Wirklichkeit.
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select
name={def.key}
defaultValue={current}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
>
{(def.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{locale === "de" ? option.labelDe : option.labelEn}
</option>
))}
</select>
</label>
);
}
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
{def.type === "textarea" ? (
<textarea
name={def.key}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={def.key}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
)}
</label>
);
}
const isTextarea = def.type !== "text";
return (
<div className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
<p className="text-sm text-ink/70 mb-2">{label}</p>
<div className="grid sm:grid-cols-2 gap-4">
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
{isTextarea ? (
<textarea
name={`de::${def.key}`}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`de::${def.key}`}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
{isTextarea ? (
<textarea
name={`en::${def.key}`}
rows={rows}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`en::${def.key}`}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
</div>
</div>
);
}
/** Ein <form> je Inhaltsgruppe auf ihrem Reiter (admin/einstellungen/page.tsx).
* updateSiteContentAction liest pro Absenden nur die in DIESEM Formular
* vorhandenen Registry-Felder (siehe admin/actions/content.ts) und springt
* danach ueber das mitgeschickte group="..." auf denselben Reiter zurueck. */
export function GroupForm({
groupHeading,
fields,
current,
groupKeyValue,
locale,
t,
}: {
groupHeading: string;
fields: ContentFieldDef[];
current: CurrentValues;
groupKeyValue: string;
locale: Locale;
t: Dictionary;
}) {
if (fields.length === 0) return null;
return (
<form
action={updateSiteContentAction}
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
>
<input type="hidden" name="group" value={groupKeyValue} />
<h3 className="text-lg text-heading">{groupHeading}</h3>
{fields.map((def) => (
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
))}
<Button type="submit" className="px-5 py-2.5">
{t.adminContentPage.save}
</Button>
</form>
);
}
/** Alle Felder einer Inhaltsgruppe, Bilder und Video zuerst — so steht auf jedem Reiter das Visuelle oben. */
export function fieldsForGroup(group: ContentFieldDef["group"]): ContentFieldDef[] {
const fields = CONTENT_REGISTRY.filter((f) => f.group === group);
return [...fields.filter(isImageOrVideoField), ...fields.filter((f) => !isImageOrVideoField(f))];
}
+142
View File
@@ -0,0 +1,142 @@
import Link from "next/link";
import { BackLink } from "@/components/back-link";
import { AdminBadge } from "@/components/admin-list";
import type { ContentFieldDef } from "@/lib/content-registry";
import { readIntegrationStatus } from "@/lib/integration-status";
import { SETTINGS_PATH, SETTINGS_TABS, parseSettingsTab, type SettingsTabId } from "@/lib/settings-tabs";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form";
import { SmtpTestPanel } from "./smtp-test-panel";
/**
* Alle Einstellungen des Shops an einem Ort, nach Bereichen in Reiter
* gegliedert (lib/settings-tabs.ts). Die Reiter sind echte Links
* (?reiter=…): direkt aufrufbar, als Lesezeichen speicherbar und ohne
* JavaScript bedienbar. Frueher getrennt: /admin/inhalte und
* /admin/e-mail-test — beide leiten hierher um.
*/
export default async function AdminEinstellungenPage(props: PageProps<"/admin/einstellungen">) {
const locale = await getLocale();
const t = getDictionary(locale);
const s = t.adminContentPage;
const { reiter } = await props.searchParams;
const activeId = parseSettingsTab(reiter);
const active = SETTINGS_TABS.find((tab) => tab.id === activeId) ?? SETTINGS_TABS[0];
const tabLabels: Record<SettingsTabId, string> = {
firma: s.tabCompany,
marke: s.tabBrand,
startseite: s.tabHome,
seiten: s.tabPages,
social: s.tabSocial,
recht: s.tabLegal,
system: s.tabSystem,
};
const groupHeadings: Record<ContentFieldDef["group"], string> = {
company: s.groupCompany,
shipping: s.groupShipping,
brand: s.groupBrand,
home: s.groupHome,
pages: s.groupPages,
social: s.groupSocial,
legal: s.groupLegal,
};
const current = active.groups.length > 0 ? await loadCurrentValues() : null;
return (
<div>
<BackLink href="/admin" label={s.backToDashboard} />
<h1 className="mb-6 text-2xl text-heading">{s.title}</h1>
<nav aria-label={s.tabsLabel} className="-mx-1 mb-8 overflow-x-auto border-b border-ink/10">
<ul className="flex min-w-max gap-1 px-1">
{SETTINGS_TABS.map((tab) => {
const isActive = tab.id === active.id;
return (
<li key={tab.id}>
<Link
href={`${SETTINGS_PATH}?reiter=${tab.id}`}
aria-current={isActive ? "page" : undefined}
className={`-mb-px block border-b-2 px-4 py-3 text-sm transition-colors ${
isActive ? "border-honey text-heading" : "border-transparent text-ink/65 hover:border-ink/20 hover:text-heading"
}`}
>
{tabLabels[tab.id]}
</Link>
</li>
);
})}
</ul>
</nav>
{active.id === "system" ? (
<SystemTab t={t} />
) : (
<>
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
{current
? active.groups.map((group) => (
<GroupForm
key={group}
groupHeading={groupHeadings[group]}
groupKeyValue={group}
fields={fieldsForGroup(group)}
current={current}
locale={locale}
t={t}
/>
))
: null}
</>
)}
</div>
);
}
function SystemTab({ t }: { t: Dictionary }) {
const s = t.adminContentPage;
const integrations = readIntegrationStatus();
const warnings = { "stripe-test-mode": s.warningStripeTest, "stripe-mixed-mode": s.warningStripeMixed } as const;
return (
<div className="space-y-12">
<p className="max-w-2xl text-sm text-ink/65">{s.systemIntro}</p>
<section aria-labelledby="settings-integrations">
<h2 id="settings-integrations" className="text-xl text-heading">
{s.integrationsHeading}
</h2>
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{s.integrationsIntro}</p>
<ul className="max-w-3xl divide-y divide-ink/10 border-y border-ink/10">
{integrations.map((integration) => (
<li key={integration.key} className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2 py-4">
<div className="min-w-0">
<p className="text-heading">{s.integrationNames[integration.key]}</p>
<p className="mt-0.5 text-xs text-ink/60">{integration.required ? s.statusRequired : s.statusOptional}</p>
{integration.warning ? (
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{warnings[integration.warning]}</p>
) : null}
{!integration.configured ? (
<p className="mt-2 break-words font-mono text-xs text-ink/70">{s.statusMissing(integration.missing.join(", "))}</p>
) : null}
</div>
<AdminBadge tone={integration.configured ? (integration.warning ? "danger" : "positive") : integration.required ? "danger" : "neutral"}>
{integration.configured ? s.statusConfigured : s.statusNotConfigured}
</AdminBadge>
</li>
))}
</ul>
</section>
<section aria-labelledby="settings-email-test">
<h2 id="settings-email-test" className="text-xl text-heading">
{s.emailTestHeading}
</h2>
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{t.adminEmailTestPage.intro}</p>
<SmtpTestPanel />
</section>
</div>
);
}
@@ -3,7 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider";
import { runSmtpTestAction, type SmtpTestState } from "./actions";
import { runSmtpTestAction, type SmtpTestState } from "./smtp-test-actions";
export function SmtpTestPanel() {
const { t } = useI18n();
@@ -52,6 +52,25 @@ export function SmtpTestPanel() {
</div>
</div>
) : null}
{state && "network" in state ? (
<div className="mt-6 text-sm">
<p className="text-xs tracking-wide-caps text-ink/60">{s.networkHeading}</p>
<ul className="mt-2 space-y-1.5">
{[
{ label: s.networkSmtp, check: state.network.smtp },
{ label: s.networkEntra, check: state.network.entra },
].map(({ label, check }) => (
<li key={label} className="flex flex-wrap items-baseline gap-x-3">
<span className="text-ink/80">{label}:</span>
<span className={check.ok ? "text-heading" : "font-medium text-red-700 dark:text-red-300"}>
{check.ok ? s.networkOk : s.networkFailed}
</span>
<span className="font-mono text-xs text-ink/60">{check.detail}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);
+5 -253
View File
@@ -1,255 +1,7 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminImageField } from "@/components/admin-image-field";
import { AdminVideoField } from "@/components/admin-video-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
import { Button } from "@/components/ui";
import { redirect } from "next/navigation";
import { SETTINGS_PATH } from "@/lib/settings-tabs";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1,
textarea: 3,
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
// nur vorhanden, damit dieses Record vollstaendig bleibt.
select: 0,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
// weiter vollstaendig ist.
image: 0,
video: 0,
};
type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
async function loadCurrentValues(): Promise<CurrentValues> {
const rows = await prisma.siteContent.findMany();
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
}
/** Bild- und Video-Felder gehoeren zum "Bilder"-Bereich, alles andere
* (Text/Listen/Rechtstexte) zum "Text"-Bereich — siehe AdminInhaltePage. */
function isImageOrVideoField(def: ContentFieldDef): boolean {
return def.type === "image" || def.type === "video";
}
function FieldEditor({
def,
row,
locale,
t,
}: {
def: ContentFieldDef;
row: { valueDe: string; valueEn: string | null } | undefined;
locale: Locale;
t: Dictionary;
}) {
const rows = ROWS_BY_TYPE[def.type];
const label = locale === "de" ? def.labelDe : def.labelEn;
if (!def.localized) {
if (def.type === "image") {
return <AdminImageField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "video") {
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "select") {
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
// darstellen als die Wirklichkeit.
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select
name={def.key}
defaultValue={current}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
>
{(def.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{locale === "de" ? option.labelDe : option.labelEn}
</option>
))}
</select>
</label>
);
}
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
{def.type === "textarea" ? (
<textarea
name={def.key}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={def.key}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
)}
</label>
);
}
const isTextarea = def.type !== "text";
return (
<div className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
<p className="text-sm text-ink/70 mb-2">{label}</p>
<div className="grid sm:grid-cols-2 gap-4">
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
{isTextarea ? (
<textarea
name={`de::${def.key}`}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`de::${def.key}`}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
{isTextarea ? (
<textarea
name={`en::${def.key}`}
rows={rows}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`en::${def.key}`}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
</div>
</div>
);
}
/** Ein <form> pro Gruppe UND Bereich (Bilder/Text) — siehe AdminInhaltePage.
* Zwei Formulare koennen dieselbe group="..." fuers Audit-Log/Reload
* mitschicken, das ist unproblematisch: updateSiteContentAction liest pro
* Absenden nur die tatsaechlich in DIESEM Formular vorhandenen Registry-
* Felder (siehe admin/actions/content.ts), Felder aus dem jeweils anderen
* Formular fehlen im FormData schlicht und werden uebersprungen. */
function GroupForm({
groupHeading,
fields,
current,
groupKeyValue,
locale,
t,
}: {
groupHeading: string;
fields: ContentFieldDef[];
current: CurrentValues;
groupKeyValue: string;
locale: Locale;
t: Dictionary;
}) {
if (fields.length === 0) return null;
return (
<form
action={updateSiteContentAction}
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
>
<input type="hidden" name="group" value={groupKeyValue} />
<h3 className="text-lg text-heading">{groupHeading}</h3>
{fields.map((def) => (
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
))}
<Button type="submit" className="px-5 py-2.5">
{t.adminContentPage.save}
</Button>
</form>
);
}
export default async function AdminInhaltePage() {
const locale = await getLocale();
const t = getDictionary(locale);
const current = await loadCurrentValues();
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
{ key: "brand", heading: t.adminContentPage.groupBrand },
{ key: "company", heading: t.adminContentPage.groupCompany },
{ key: "shipping", heading: t.adminContentPage.groupShipping },
{ key: "home", heading: t.adminContentPage.groupHome },
{ key: "pages", heading: t.adminContentPage.groupPages },
{ key: "social", heading: t.adminContentPage.groupSocial },
{ key: "legal", heading: t.adminContentPage.groupLegal },
];
return (
<div>
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
<h1 className="text-2xl text-heading mb-2">{t.adminContentPage.title}</h1>
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
<section className="mb-12">
<h2 className="text-xl text-heading mb-1">{t.adminContentPage.sectionImages}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
{groups.map((group) => (
<GroupForm
key={`images-${group.key}`}
groupHeading={group.heading}
groupKeyValue={group.key}
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && isImageOrVideoField(f))}
current={current}
locale={locale}
t={t}
/>
))}
</section>
<section>
<h2 className="text-xl text-heading mb-1">{t.adminContentPage.sectionText}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionTextIntro}</p>
{groups.map((group) => (
<GroupForm
key={`text-${group.key}`}
groupHeading={group.heading}
groupKeyValue={group.key}
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && !isImageOrVideoField(f))}
current={current}
locale={locale}
t={t}
/>
))}
</section>
</div>
);
/** Frueher "Inhalte" — jetzt Teil von Admin → Einstellungen. Alte Lesezeichen fuehren weiter. */
export default function AdminInhalteRedirect(): never {
redirect(SETTINGS_PATH);
}
+1 -2
View File
@@ -57,9 +57,8 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte
{
label: t.adminShell.groupSite,
items: [
{ href: "/admin/inhalte", label: t.adminShell.navContent },
{ href: "/admin/einstellungen", label: t.adminShell.navContent },
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
{ href: "/admin/e-mail-test", label: t.adminShell.navEmailTest },
],
},
];
+36 -3
View File
@@ -1269,6 +1269,11 @@ export const de: Dictionary = {
detailLabel: "Meldung des Mailservers",
configLabel: "Verwendete Einstellungen",
authModeOAuth: "Microsoft 365, OAuth 2.0 über die SSO-App",
networkHeading: "Netzwerk (IPv4)",
networkSmtp: "smtp.office365.com, Port 587",
networkEntra: "login.microsoftonline.com, Port 443",
networkOk: "erreichbar",
networkFailed: "nicht erreichbar",
missingLabel: "Fehlende Einstellungen",
},
adminWithdrawalsPage: {
@@ -1442,8 +1447,37 @@ export const de: Dictionary = {
chartTooltip: (day, value) => `${day}: ${value}`,
},
adminContentPage: {
tabsLabel: "Bereiche der Einstellungen",
tabCompany: "Firma & Versand",
tabBrand: "Marke",
tabHome: "Startseite",
tabPages: "Seiten",
tabSocial: "Social Media",
tabLegal: "Rechtstexte",
tabSystem: "System & Tests",
systemIntro: "Prüft, ob der Shop mit allen angebundenen Diensten arbeiten kann. Werte aus dem Kubernetes-Secret werden hier nie angezeigt, nur ob sie gesetzt sind.",
emailTestHeading: "E-Mail-Versand",
integrationsHeading: "Anbindungen",
integrationsIntro: "Welche Dienste eingerichtet sind. Fehlt etwas, stehen die Namen der fehlenden Werte daneben — einzutragen im Kubernetes-Secret bzw. in der ConfigMap (siehe docs/EINRICHTUNG.md).",
statusConfigured: "Eingerichtet",
statusNotConfigured: "Nicht eingerichtet",
statusMissing: (names) => `Fehlt: ${names}`,
statusRequired: "Pflicht",
statusOptional: "Optional",
warningStripeTest: "Testschlüssel aktiv — Bestellungen sehen erfolgreich aus, es fließt aber kein Geld.",
warningStripeMixed: "Geheimer und öffentlicher Schlüssel stammen aus verschiedenen Modi (test/live) — die Kasse funktioniert so nicht.",
integrationNames: {
stripe: "Stripe (Zahlung)",
stripeWebhook: "Stripe-Webhook (Zahlungsbestätigung)",
mail: "E-Mail-Versand (Microsoft 365)",
sso: "Mit Microsoft anmelden",
turnstile: "Cloudflare Turnstile (CAPTCHA)",
googleReviews: "Google-Rezensionen",
dhl: "DHL (Versandmarken)",
apicore: "ApiCore-Synchronisierung",
},
backToDashboard: "Zurück zum Dashboard",
title: "Inhalte",
title: "Einstellungen",
intro:
"Diese Texte erscheinen auf der Website. Ein leeres Feld bedeutet nicht \"leer auf der Seite\": Dann wird der ursprüngliche Text weiter angezeigt. Trage nur ein, was du ändern möchtest.",
sectionImages: "Bilder",
@@ -1510,8 +1544,7 @@ export const de: Dictionary = {
navVouchers: "Gutscheine",
navReviews: "Bewertungen",
navAuditLog: "Audit-Log",
navEmailTest: "E-Mail-Test",
navContent: "Inhalte",
navContent: "Einstellungen",
navAriaLabel: "Admin-Navigation",
mobileNavAriaLabel: "Mobile Admin-Navigation",
openMenu: "Menü öffnen",
+36 -3
View File
@@ -1243,6 +1243,11 @@ export const en: Dictionary = {
detailLabel: "Mail server message",
configLabel: "Settings used",
authModeOAuth: "Microsoft 365, OAuth 2.0 via the SSO app",
networkHeading: "Network (IPv4)",
networkSmtp: "smtp.office365.com, port 587",
networkEntra: "login.microsoftonline.com, port 443",
networkOk: "reachable",
networkFailed: "not reachable",
missingLabel: "Missing settings",
},
adminWithdrawalsPage: {
@@ -1416,8 +1421,37 @@ export const en: Dictionary = {
chartTooltip: (day, value) => `${day}: ${value}`,
},
adminContentPage: {
tabsLabel: "Settings sections",
tabCompany: "Company & shipping",
tabBrand: "Brand",
tabHome: "Home page",
tabPages: "Pages",
tabSocial: "Social media",
tabLegal: "Legal texts",
tabSystem: "System & tests",
systemIntro: "Checks whether the shop can work with all connected services. Values from the Kubernetes secret are never shown here, only whether they are set.",
emailTestHeading: "Email delivery",
integrationsHeading: "Integrations",
integrationsIntro: "Which services are set up. If something is missing, the names of the missing values are listed — to be added to the Kubernetes secret or config map (see docs/EINRICHTUNG.md).",
statusConfigured: "Set up",
statusNotConfigured: "Not set up",
statusMissing: (names) => `Missing: ${names}`,
statusRequired: "Required",
statusOptional: "Optional",
warningStripeTest: "Test keys active — orders look successful, but no money is collected.",
warningStripeMixed: "Secret and publishable key come from different modes (test/live) — checkout will not work like this.",
integrationNames: {
stripe: "Stripe (payments)",
stripeWebhook: "Stripe webhook (payment confirmation)",
mail: "Email delivery (Microsoft 365)",
sso: "Sign in with Microsoft",
turnstile: "Cloudflare Turnstile (CAPTCHA)",
googleReviews: "Google reviews",
dhl: "DHL (shipping labels)",
apicore: "ApiCore sync",
},
backToDashboard: "Back to dashboard",
title: "Content",
title: "Settings",
intro:
"These texts appear on the website. An empty field does not mean \"blank on the page\": the original text keeps showing. Only fill in what you want to change.",
sectionImages: "Images",
@@ -1484,8 +1518,7 @@ export const en: Dictionary = {
navVouchers: "Vouchers",
navReviews: "Reviews",
navAuditLog: "Audit log",
navEmailTest: "Email test",
navContent: "Content",
navContent: "Settings",
navAriaLabel: "Admin navigation",
mobileNavAriaLabel: "Mobile admin navigation",
openMenu: "Open menu",
+25 -1
View File
@@ -830,6 +830,11 @@ export interface Dictionary {
detailLabel: string;
configLabel: string;
authModeOAuth: string;
networkHeading: string;
networkSmtp: string;
networkEntra: string;
networkOk: string;
networkFailed: string;
missingLabel: string;
};
adminWithdrawalsPage: {
@@ -996,6 +1001,26 @@ export interface Dictionary {
chartTooltip: (day: string, value: string) => string;
};
adminContentPage: {
tabsLabel: string;
tabCompany: string;
tabBrand: string;
tabHome: string;
tabPages: string;
tabSocial: string;
tabLegal: string;
tabSystem: string;
systemIntro: string;
emailTestHeading: string;
integrationsHeading: string;
integrationsIntro: string;
statusConfigured: string;
statusNotConfigured: string;
statusMissing: (names: string) => string;
statusRequired: string;
statusOptional: string;
warningStripeTest: string;
warningStripeMixed: string;
integrationNames: Record<"stripe" | "stripeWebhook" | "mail" | "sso" | "turnstile" | "googleReviews" | "dhl" | "apicore", string>;
backToDashboard: string;
title: string;
intro: string;
@@ -1059,7 +1084,6 @@ export interface Dictionary {
navVouchers: string;
navReviews: string;
navAuditLog: string;
navEmailTest: string;
navContent: string;
navAriaLabel: string;
mobileNavAriaLabel: string;
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { readIntegrationStatus } from "./integration-status";
const FULL = {
STRIPE_SECRET_KEY: "sk_live_abc",
STRIPE_PUBLISHABLE_KEY: "pk_live_abc",
STRIPE_WEBHOOK_SECRET: "whsec_abc",
SMTP_USER: "[email protected]",
ENTRA_TENANT_ID: "t",
ENTRA_CLIENT_ID: "c",
ENTRA_CLIENT_SECRET: "s",
ENTRA_REDIRECT_URI: "https://wabenhain.com/api/auth/entra/callback",
TURNSTILE_SECRET_KEY: "x",
NEXT_PUBLIC_TURNSTILE_SITE_KEY: "y",
GOOGLE_PLACES_API_KEY: "g",
GOOGLE_PLACE_ID: "p",
DHL_API_KEY: "a",
DHL_API_SECRET: "b",
DHL_GKP_USERNAME: "u",
DHL_GKP_PASSWORD: "w",
DHL_ACCOUNT_NUMBER: "12345678901234",
APICORE_API_URL: "https://api.example.org/api",
APICORE_SYNC_EMAIL: "[email protected]",
APICORE_SYNC_PASSWORD: "pw",
};
describe("readIntegrationStatus", () => {
it("reports every integration as configured when all values are set", () => {
const status = readIntegrationStatus(FULL);
expect(status.every((s) => s.configured)).toBe(true);
expect(status.map((s) => s.key)).toEqual(["stripe", "stripeWebhook", "mail", "sso", "turnstile", "googleReviews", "dhl", "apicore"]);
});
it("names exactly the missing variables", () => {
const mail = readIntegrationStatus({ ...FULL, SMTP_USER: "", ENTRA_CLIENT_SECRET: " " }).find((s) => s.key === "mail");
expect(mail).toMatchObject({ configured: false, missing: ["SMTP_USER", "ENTRA_CLIENT_SECRET"] });
});
it("flags Stripe test keys", () => {
const stripe = readIntegrationStatus({ ...FULL, STRIPE_SECRET_KEY: "sk_test_abc", STRIPE_PUBLISHABLE_KEY: "pk_test_abc" }).find((s) => s.key === "stripe");
expect(stripe).toMatchObject({ configured: true, warning: "stripe-test-mode" });
});
it("flags mixed Stripe modes", () => {
const stripe = readIntegrationStatus({ ...FULL, STRIPE_PUBLISHABLE_KEY: "pk_test_abc" }).find((s) => s.key === "stripe");
expect(stripe?.warning).toBe("stripe-mixed-mode");
});
it("never returns a configured value", () => {
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/);
});
it("marks shop-critical integrations as required", () => {
const required = readIntegrationStatus({}).filter((s) => s.required).map((s) => s.key);
expect(required).toEqual(["stripe", "stripeWebhook", "mail"]);
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Welche Anbindungen sind eingerichtet? Fuer den Reiter "System & Tests" in
* Admin → Einstellungen. Liefert NUR Namen fehlender Variablen und ob etwas
* gesetzt ist — nie einen Wert, auch keinen Teil davon (Ausnahme: der Modus
* test/live der Stripe-Schluessel, abgeleitet aus dem Praefix).
*/
export type IntegrationKey = "stripe" | "stripeWebhook" | "mail" | "sso" | "turnstile" | "googleReviews" | "dhl" | "apicore";
export type IntegrationWarning = "stripe-test-mode" | "stripe-mixed-mode";
export interface IntegrationStatus {
key: IntegrationKey;
configured: boolean;
/** Ohne diese Anbindung kann der Shop nicht verkaufen oder bestaetigen. */
required: boolean;
missing: string[];
warning?: IntegrationWarning;
}
type Env = Record<string, string | undefined>;
const INTEGRATIONS: { key: IntegrationKey; required: boolean; variables: string[] }[] = [
{ key: "stripe", required: true, variables: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY"] },
{ key: "stripeWebhook", required: true, variables: ["STRIPE_WEBHOOK_SECRET"] },
{ key: "mail", required: true, variables: ["SMTP_USER", "ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET"] },
{ key: "sso", required: false, variables: ["ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET", "ENTRA_REDIRECT_URI"] },
{ key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] },
{ key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] },
{ key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] },
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_SYNC_EMAIL", "APICORE_SYNC_PASSWORD"] },
];
function stripeMode(key: string): "live" | "test" | null {
if (/^(sk|rk|pk)_live_/.test(key)) return "live";
if (/^(sk|rk|pk)_test_/.test(key)) return "test";
return null;
}
function stripeWarning(env: Env): IntegrationWarning | undefined {
const secret = stripeMode(env.STRIPE_SECRET_KEY?.trim() ?? "");
const publishable = stripeMode(env.STRIPE_PUBLISHABLE_KEY?.trim() ?? "");
if (secret && publishable && secret !== publishable) return "stripe-mixed-mode";
if (secret === "test" || publishable === "test") return "stripe-test-mode";
return undefined;
}
export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] {
return INTEGRATIONS.map(({ key, required, variables }) => {
const missing = variables.filter((name) => !env[name]?.trim());
const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing };
const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined;
return warning ? { ...status, warning } : status;
});
}
+76 -29
View File
@@ -1,3 +1,5 @@
import { promises as dns } from "node:dns";
import net from "node:net";
import nodemailer, { type Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { explainSmtpError, type SmtpDiagnosis } from "@/lib/smtp-diagnose";
@@ -44,7 +46,9 @@ interface SmtpConfig {
const MS365_SMTP_HOST = "smtp.office365.com";
/** STARTTLS — der Port, den Microsoft fuer SMTP-Client-Submission vorsieht. */
const MS365_SMTP_PORT = 587;
const ENTRA_HOST = "login.microsoftonline.com";
const TEST_TIMEOUT_MS = 15_000;
const PROBE_TIMEOUT_MS = 8_000;
type SmtpTransporter = Transporter<SMTPTransport.SentMessageInfo>;
@@ -87,15 +91,39 @@ function getTokenProvider(app: EntraAppCredentials): Ms365TokenProvider {
return tokenProvider.provider;
}
function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): SmtpTransporter {
/**
* Nur IPv4: nodemailer loest den Host selbst auf und waehlt ZUFAELLIG eine
* der IPv4- und IPv6-Adressen. In Clustern mit IPv6-Schnittstelle, aber ohne
* IPv6-Route nach aussen, scheitert das mit "ENETUNREACH 2603:…" — je nach
* Los mal ja, mal nein. Microsoft 365 ist per IPv4 immer erreichbar; die
* TLS-Pruefung bleibt ueber servername an smtp.office365.com gebunden.
*/
async function resolveSmtpIpv4(): Promise<string> {
const addresses = await dns.resolve4(MS365_SMTP_HOST);
if (addresses.length === 0) {
throw Object.assign(new Error(`Keine IPv4-Adresse fuer ${MS365_SMTP_HOST} gefunden`), { code: "EDNS" });
}
return addresses[Math.floor(Math.random() * addresses.length)];
}
/**
* Baut je Versand einen frischen Transport: die IPv4-Adresse von
* smtp.office365.com wechselt, ein dauerhaft gemerkter Transport hinge an
* einer Adresse, die Microsoft irgendwann nicht mehr bedient.
*/
async function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number; greetingTimeout?: number } = {}): Promise<SmtpTransporter> {
const address = await resolveSmtpIpv4();
const options: SMTPTransport.Options = {
host: MS365_SMTP_HOST,
host: address,
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,
// Verbunden wird per IP, geprueft wird das Zertifikat trotzdem gegen den
// Namen — sonst liesse sich die Verbindung unbemerkt umleiten.
tls: { servername: MS365_SMTP_HOST },
auth: {
type: "OAuth2",
user: config.user,
@@ -114,27 +142,11 @@ function buildTransport(config: SmtpConfig, extra: { connectionTimeout?: number;
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.
let warnedAboutMissingSmtpInProduction = false;
function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | null {
const config = readSmtpConfig();
if (!config) {
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
warnedAboutMissingSmtpInProduction = true;
console.warn(
`[mail] Mailversand nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.`
);
}
return null;
}
if (!cachedTransport) cachedTransport = { config, transport: buildTransport(config) };
return cachedTransport;
}
/**
* Versendet eine E-Mail ueber Microsoft 365. Ohne Konfiguration wird die
* Mail NICHT gesendet, sondern nur geloggt — damit lokale Entwicklung und
@@ -149,14 +161,22 @@ function getTransport(): { config: SmtpConfig; transport: SmtpTransporter } | nu
* Kundschaft auf eine Mail, die nie kommt.
*/
export async function sendMail(input: SendMailInput): Promise<boolean> {
const active = getTransport();
if (!active) {
const config = readSmtpConfig();
if (!config) {
if (process.env.NODE_ENV === "production" && !warnedAboutMissingSmtpInProduction) {
warnedAboutMissingSmtpInProduction = true;
console.warn(
`[mail] Mailversand nicht vollstaendig eingerichtet (es fehlt: ${missingSmtpSettings().join(", ")}) — Transaktions-E-Mails werden nur geloggt, nicht versendet.`
);
}
console.warn(`[mail] Mailversand nicht eingerichtet — E-Mail an ${maskEmail(input.to)} ("${input.subject}") wurde nicht gesendet.`);
return false;
}
let transport: SmtpTransporter | null = null;
try {
await active.transport.sendMail({
transport = await buildTransport(config);
await transport.sendMail({
from: getFromAddress(),
to: input.to,
subject: input.subject,
@@ -166,16 +186,40 @@ export async function sendMail(input: SendMailInput): Promise<boolean> {
});
return true;
} catch (error: unknown) {
const diagnosis = explainSmtpError(error, { secrets: [active.config.app.clientSecret] });
const diagnosis = explainSmtpError(error, { secrets: [config.app.clientSecret] });
console.error(`[mail] Versand an ${maskEmail(input.to)} fehlgeschlagen (${diagnosis.cause}): ${diagnosis.detail} — ${diagnosis.hint}`);
return false;
} finally {
transport?.close();
}
}
export interface NetworkCheck {
ok: boolean;
/** Kurze technische Angabe: erreichte IP-Adresse oder Fehlercode. */
detail: string;
}
/** Oeffnet nur eine TCP-Verbindung (IPv4) und schliesst sie sofort — ohne Daten zu senden. */
function probeTcp(host: string, port: number): Promise<NetworkCheck> {
return new Promise((resolve) => {
const socket = net.connect({ host, port, family: 4, timeout: PROBE_TIMEOUT_MS });
const finish = (result: NetworkCheck) => {
socket.destroy();
resolve(result);
};
socket.once("connect", () => finish({ ok: true, detail: `${socket.remoteAddress ?? host}:${port}` }));
socket.once("timeout", () => finish({ ok: false, detail: `Zeitueberschreitung nach ${PROBE_TIMEOUT_MS / 1000} s (Port ${port} vermutlich blockiert)` }));
socket.once("error", (error: Error & { code?: string }) => finish({ ok: false, detail: error.code ?? error.message }));
});
}
interface SmtpTestBase {
host: string;
user: string;
from: string;
/** Erreichbarkeit per IPv4 — trennt Netzwerk-/Firewall-Probleme von Konfigurationsfehlern. */
network: { smtp: NetworkCheck; entra: NetworkCheck };
}
export type SmtpTestResult =
@@ -184,18 +228,21 @@ export type SmtpTestResult =
| ({ 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
* Prueft den Mailversand im laufenden Betrieb: erst die Netzwerkwege
* (smtp.office365.com:587, login.microsoftonline.com:443), dann Token,
* Anmeldung (verify) und ein echter Versand an `to` — erst der Versand zeigt,
* ob das Postfach mit der Absenderadresse (SMTP_FROM) senden darf. 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 });
const [smtp, entra] = await Promise.all([probeTcp(MS365_SMTP_HOST, MS365_SMTP_PORT), probeTcp(ENTRA_HOST, 443)]);
const base: SmtpTestBase = { host: MS365_SMTP_HOST, user: config.user, from: getFromAddress(), network: { smtp, entra } };
let transport: SmtpTransporter | null = null;
try {
transport = await buildTransport(config, { connectionTimeout: TEST_TIMEOUT_MS, greetingTimeout: TEST_TIMEOUT_MS });
await transport.verify();
await transport.sendMail({
from: base.from,
@@ -207,6 +254,6 @@ export async function testSmtpConnection(to: string): Promise<SmtpTestResult> {
} catch (error: unknown) {
return { ok: false, configured: true, ...base, diagnosis: explainSmtpError(error, { secrets: [config.app.clientSecret] }) };
} finally {
transport.close();
transport?.close();
}
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { CONTENT_REGISTRY } from "./content-registry";
import { SETTINGS_TABS, parseSettingsTab, settingsPathForGroup } from "./settings-tabs";
describe("settings tabs", () => {
it("places every content group on exactly one tab", () => {
const groups = new Set(CONTENT_REGISTRY.map((f) => f.group));
for (const group of groups) {
expect(SETTINGS_TABS.filter((tab) => (tab.groups as readonly string[]).includes(group))).toHaveLength(1);
}
});
it("falls back to the first tab for unknown or missing values", () => {
expect(parseSettingsTab(undefined)).toBe("firma");
expect(parseSettingsTab("../../etc")).toBe("firma");
expect(parseSettingsTab(["system"])).toBe("firma");
expect(parseSettingsTab("system")).toBe("system");
});
it("returns to the tab of the saved group", () => {
expect(settingsPathForGroup("shipping")).toBe("/admin/einstellungen?reiter=firma");
expect(settingsPathForGroup("legal")).toBe("/admin/einstellungen?reiter=recht");
expect(settingsPathForGroup("evil?x=1")).toBe("/admin/einstellungen");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { ContentFieldDef } from "@/lib/content-registry";
/**
* Reiter von Admin → Einstellungen und welche Inhaltsgruppen
* (lib/content-registry.ts) sie tragen. "system" hat keine Inhaltsfelder,
* sondern Anbindungsstatus und E-Mail-Test.
*/
export const SETTINGS_TABS = [
{ id: "firma", groups: ["company", "shipping"] },
{ id: "marke", groups: ["brand"] },
{ id: "startseite", groups: ["home"] },
{ id: "seiten", groups: ["pages"] },
{ id: "social", groups: ["social"] },
{ id: "recht", groups: ["legal"] },
{ id: "system", groups: [] },
] as const satisfies readonly { id: string; groups: readonly ContentFieldDef["group"][] }[];
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
export const SETTINGS_PATH = "/admin/einstellungen";
/** Reiter aus ?reiter=…; alles Unbekannte (auch Mehrfachwerte) faellt auf den ersten Reiter zurueck. */
export function parseSettingsTab(value: string | string[] | undefined): SettingsTabId {
const match = SETTINGS_TABS.find((tab) => tab.id === value);
return match ? match.id : SETTINGS_TABS[0].id;
}
/**
* Ziel nach dem Speichern: derselbe Reiter. Der group-Wert kommt aus dem
* Formular und ist damit Nutzereingabe — nur bekannte Gruppen fuehren zu
* einem Reiter, alles andere zur Uebersicht.
*/
export function settingsPathForGroup(group: string): string {
const tab = SETTINGS_TABS.find((t) => (t.groups as readonly string[]).includes(group));
return tab ? `${SETTINGS_PATH}?reiter=${tab.id}` : SETTINGS_PATH;
}
+1 -1
View File
@@ -33,7 +33,7 @@ const HINTS: Record<SmtpFailureCause, string> = {
"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).",
"smtp.office365.com ist nicht erreichbar. Die Netzwerkprüfung oben zeigt, welcher Weg blockiert ist: Der Cluster muss ausgehend per IPv4 smtp.office365.com auf Port 587 und login.microsoftonline.com auf Port 443 erreichen (Firewall des Hosters, NetworkPolicy). Manche Hoster sperren Mail-Ports für neue Server — dann dort die Freigabe von Port 587 beantragen.",
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.",
};